Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dominant color method #4874

Closed
wants to merge 10 commits into from
18 changes: 18 additions & 0 deletions Tests/test_image_getdominantcolors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from .helper import hopper


def test_getdominantcolors():
def getdominantcolors(mode):
im = hopper(mode)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean about it being slow, pytest Tests/test_image_getdominantcolors.py takes ~5.67s on my Mac.

Resizing to (10, 10) reduces it to 0.23s.
Resizing to (20, 20) reduces it to 0.41s.

Do you think 10x10 might be too small to be useful?

Suggested change
im = hopper(mode)
im = hopper(mode)
im.thumbnail((10, 10))

Copy link
Author

@392781 392781 Aug 20, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small sizes like that give a very rough approximation especially if you're looking for a palette that's more than 3 colors. The colors returned tend to be much darker. I found that (100,100) gives somewhat decent results. The issue becomes exacerbated with large images (1080p wallpapers)

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can improve these tests so they're testing more than just they return a thing of length three.

We can also use @pytest.mark.parametrize so each test case is run independently so cannot depend on an earlier case.

Here's an example:

import pytest

from .helper import hopper


@pytest.mark.parametrize(
    "test_mode, expected",
    [
        ("F", [28.8104386146252, 66.26185757773263, 127.53211228743844]),
        ("I", [136, 94, 40]),
        ("L", [25, 63, 127]),
        ("P", [11, 71, 159]),
        ("RGB", [(172, 117, 94), (53, 44, 55), (95, 127, 185)]),
        ("YCbCr", [(130, 108, 155), (123, 163, 105), (47, 131, 131)]),
        ("CMYK", [(201, 210, 199, 0), (159, 127, 69, 0), (82, 137, 160, 0)]),
        ("RGBA", [(31, 24, 37, 255), (129, 125, 150, 255), (87, 67, 72, 255)]),
        ("HSV", [(140, 131, 85), (177, 70, 46), (97, 131, 186)]),
    ],
)
def test_getdominantcolors(test_mode, expected):
    def getdominantcolors(mode):
        im = hopper(mode)
        im.thumbnail((10, 10))
        colors = im.getdominantcolors()
        return colors

    assert getdominantcolors(test_mode) == expected

What do you think?

Do those expected return values look right?

Might they be different on other systems? Especially the first one. If so, we can adjust it somewhat, possibly have a special case for that.

It would be good to add further test_X functions to verify the other parameters of im.getdominantcolors, plus warnings and exceptions.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my earlier comment never posted. The values for HSV are way off, straight up wrong even when I tested yesterday. I think the way I implemented the algorithm doesn't play nice with the way I approximate the centers of that color space.

In addition when I convert CMYK and YCbCr to RGB they tend to be off by a few color values... This isn't too severe of a problem though.

One solution to the HSV case is to simply convert to RGBA and do the calculations there... I'm going to see if I could fix what's happening before I divert to this option.

My 2nd concern is that the returned colors are mostly subjective to the viewer. If say we used some other implementation as a base case, k-means may start out randomized and return slightly different results. Perhaps I could test within a range of accepted values?

100 changes: 100 additions & 0 deletions src/PIL/Image.py
Original file line number Diff line number Diff line change
Expand Up @@ -1272,6 +1272,106 @@ def getdata(self, band=None):
return self.im.getband(band)
return self.im # could be abused

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.

: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).
:param quality: Used for scaling an image to speed up calculations.
The default value is 1.0.
:returns: An unsorted list of pixel values.
"""

# 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
elif self.mode in ("RGBA", "CMYK"):
channels = 4
else:
radarhere marked this conversation as resolved.
Show resolved Hide resolved
tb = sys.exc_info()[2]
raise ValueError("Unsupported image mode").with_traceback(tb)
radarhere marked this conversation as resolved.
Show resolved Hide resolved
392781 marked this conversation as resolved.
Show resolved Hide resolved

def euclidean(p1, p2):
if channels == 1:
return (p1 - p2) ** 2
return sum([(p1[i] - p2[i]) ** 2 for i in range(channels)])

pixels_and_counts = []
for count, color in self.getcolors(self.width * self.height):
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]))

# 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")

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)

# Adjusting the center of each cluster
difference = 0
for i in range(numcolors):
previous = centroids[i][1]
count_sum = 0

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]

centroids[i] = (cluster[i], current)
difference = max(difference, euclidean(previous, current))

if difference < threshold:
break

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):
"""
Gets the the minimum and maximum pixel values for each band in
Expand Down