├── MANIFEST.in ├── setup.cfg ├── .gitignore ├── Makefile ├── CHANGES ├── examples └── demo.py ├── setup.py ├── LICENSE ├── README.rst └── colorthief.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE *.py 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.pyo 4 | *.egg-info 5 | dist 6 | build 7 | docs/_build 8 | .tox/* 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean-pyc: 2 | find . -name '*.pyc' -exec rm -f {} + 3 | find . -name '*.pyo' -exec rm -f {} + 4 | find . -name '*~' -exec rm -f {} + 5 | 6 | lines: 7 | find . -name "*.py"|xargs cat|wc -l 8 | 9 | release: 10 | python setup.py register 11 | python setup.py sdist upload 12 | python setup.py bdist_wheel upload 13 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Color Thief Changelog 2 | ===================== 3 | 4 | 5 | Version 0.1 6 | ----------- 7 | 8 | First public preview release. 9 | 10 | 11 | Version 0.2 12 | ----------- 13 | 14 | Released on Oct 14th 2015 15 | 16 | - Added Python3.x support 17 | 18 | Version 0.2.1 19 | ------------- 20 | 21 | Released on Feb 9th 2017 22 | 23 | - Removed useless list creation 24 | -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | 5 | if sys.version_info < (3, 0): 6 | from urllib2 import urlopen 7 | else: 8 | from urllib.request import urlopen 9 | 10 | import io 11 | 12 | from colorthief import ColorThief 13 | 14 | 15 | fd = urlopen('http://lokeshdhakar.com/projects/color-thief/img/photo1.jpg') 16 | f = io.BytesIO(fd.read()) 17 | color_thief = ColorThief(f) 18 | print(color_thief.get_color(quality=1)) 19 | print(color_thief.get_palette(quality=1)) 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Color Thief 3 | ----------- 4 | 5 | A module for grabbing the color palette from an image. 6 | 7 | Links 8 | ````` 9 | 10 | * `github `_ 11 | * `development version 12 | `_ 13 | 14 | """ 15 | from setuptools import setup 16 | 17 | 18 | setup( 19 | name='colorthief', 20 | version='0.2.1', 21 | url='https://github.com/fengsp/color-thief-py', 22 | license='BSD', 23 | author='Shipeng Feng', 24 | author_email='fsp261@gmail.com', 25 | description='A module for grabbing the color palette from an image.', 26 | long_description=__doc__, 27 | py_modules=['colorthief'], 28 | install_requires=[ 29 | 'Pillow' 30 | ], 31 | zip_safe=False, 32 | classifiers=[ 33 | 'Development Status :: 4 - Beta', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: BSD License', 36 | 'Programming Language :: Python', 37 | 'Programming Language :: Python :: 2.6', 38 | 'Programming Language :: Python :: 2.7', 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 by Shipeng Feng. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 24 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Color Thief 2 | =========== 3 | 4 | A Python module for grabbing the color palette from an image. 5 | 6 | Installation 7 | ------------ 8 | 9 | :: 10 | 11 | $ pip install colorthief 12 | 13 | Usage 14 | ----- 15 | 16 | .. code:: python 17 | 18 | from colorthief import ColorThief 19 | 20 | color_thief = ColorThief('/path/to/imagefile') 21 | # get the dominant color 22 | dominant_color = color_thief.get_color(quality=1) 23 | # build a color palette 24 | palette = color_thief.get_palette(color_count=6) 25 | 26 | API 27 | --- 28 | 29 | .. code:: python 30 | 31 | class ColorThief(object): 32 | def __init__(self, file): 33 | """Create one color thief for one image. 34 | 35 | :param file: A filename (string) or a file object. The file object 36 | must implement `read()`, `seek()`, and `tell()` methods, 37 | and be opened in binary mode. 38 | """ 39 | pass 40 | 41 | def get_color(self, quality=10): 42 | """Get the dominant color. 43 | 44 | :param quality: quality settings, 1 is the highest quality, the bigger 45 | the number, the faster a color will be returned but 46 | the greater the likelihood that it will not be the 47 | visually most dominant color 48 | :return tuple: (r, g, b) 49 | """ 50 | pass 51 | 52 | def get_palette(self, color_count=10, quality=10): 53 | """Build a color palette. We are using the median cut algorithm to 54 | cluster similar colors. 55 | 56 | :param color_count: the size of the palette, max number of colors 57 | :param quality: quality settings, 1 is the highest quality, the bigger 58 | the number, the faster the palette generation, but the 59 | greater the likelihood that colors will be missed. 60 | :return list: a list of tuple in the form (r, g, b) 61 | """ 62 | pass 63 | 64 | Thanks 65 | ------ 66 | 67 | Thanks to Lokesh Dhakar for his `original work 68 | `_. 69 | 70 | Better 71 | ------ 72 | 73 | If you feel anything wrong, feedbacks or pull requests are welcome. 74 | -------------------------------------------------------------------------------- /colorthief.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | colorthief 4 | ~~~~~~~~~~ 5 | 6 | Grabbing the color palette from an image. 7 | 8 | :copyright: (c) 2015 by Shipeng Feng. 9 | :license: BSD, see LICENSE for more details. 10 | """ 11 | __version__ = '0.2.1' 12 | 13 | import math 14 | 15 | from PIL import Image 16 | 17 | 18 | class cached_property(object): 19 | """Decorator that creates converts a method with a single 20 | self argument into a property cached on the instance. 21 | """ 22 | def __init__(self, func): 23 | self.func = func 24 | 25 | def __get__(self, instance, type): 26 | res = instance.__dict__[self.func.__name__] = self.func(instance) 27 | return res 28 | 29 | 30 | class ColorThief(object): 31 | """Color thief main class.""" 32 | def __init__(self, file): 33 | """Create one color thief for one image. 34 | 35 | :param file: A filename (string) or a file object. The file object 36 | must implement `read()`, `seek()`, and `tell()` methods, 37 | and be opened in binary mode. 38 | """ 39 | self.image = Image.open(file) 40 | 41 | def get_color(self, quality=10): 42 | """Get the dominant color. 43 | 44 | :param quality: quality settings, 1 is the highest quality, the bigger 45 | the number, the faster a color will be returned but 46 | the greater the likelihood that it will not be the 47 | visually most dominant color 48 | :return tuple: (r, g, b) 49 | """ 50 | palette = self.get_palette(5, quality) 51 | return palette[0] 52 | 53 | def get_palette(self, color_count=10, quality=10): 54 | """Build a color palette. We are using the median cut algorithm to 55 | cluster similar colors. 56 | 57 | :param color_count: the size of the palette, max number of colors 58 | :param quality: quality settings, 1 is the highest quality, the bigger 59 | the number, the faster the palette generation, but the 60 | greater the likelihood that colors will be missed. 61 | :return list: a list of tuple in the form (r, g, b) 62 | """ 63 | image = self.image.convert('RGBA') 64 | width, height = image.size 65 | pixels = image.getdata() 66 | pixel_count = width * height 67 | valid_pixels = [] 68 | for i in range(0, pixel_count, quality): 69 | r, g, b, a = pixels[i] 70 | # If pixel is mostly opaque and not white 71 | if a >= 125: 72 | if not (r > 250 and g > 250 and b > 250): 73 | valid_pixels.append((r, g, b)) 74 | 75 | # Send array to quantize function which clusters values 76 | # using median cut algorithm 77 | cmap = MMCQ.quantize(valid_pixels, color_count) 78 | return cmap.palette 79 | 80 | 81 | class MMCQ(object): 82 | """Basic Python port of the MMCQ (modified median cut quantization) 83 | algorithm from the Leptonica library (http://www.leptonica.com/). 84 | """ 85 | 86 | SIGBITS = 5 87 | RSHIFT = 8 - SIGBITS 88 | MAX_ITERATION = 1000 89 | FRACT_BY_POPULATIONS = 0.75 90 | 91 | @staticmethod 92 | def get_color_index(r, g, b): 93 | return (r << (2 * MMCQ.SIGBITS)) + (g << MMCQ.SIGBITS) + b 94 | 95 | @staticmethod 96 | def get_histo(pixels): 97 | """histo (1-d array, giving the number of pixels in each quantized 98 | region of color space) 99 | """ 100 | histo = dict() 101 | for pixel in pixels: 102 | rval = pixel[0] >> MMCQ.RSHIFT 103 | gval = pixel[1] >> MMCQ.RSHIFT 104 | bval = pixel[2] >> MMCQ.RSHIFT 105 | index = MMCQ.get_color_index(rval, gval, bval) 106 | histo[index] = histo.setdefault(index, 0) + 1 107 | return histo 108 | 109 | @staticmethod 110 | def vbox_from_pixels(pixels, histo): 111 | rmin = 1000000 112 | rmax = 0 113 | gmin = 1000000 114 | gmax = 0 115 | bmin = 1000000 116 | bmax = 0 117 | for pixel in pixels: 118 | rval = pixel[0] >> MMCQ.RSHIFT 119 | gval = pixel[1] >> MMCQ.RSHIFT 120 | bval = pixel[2] >> MMCQ.RSHIFT 121 | rmin = min(rval, rmin) 122 | rmax = max(rval, rmax) 123 | gmin = min(gval, gmin) 124 | gmax = max(gval, gmax) 125 | bmin = min(bval, bmin) 126 | bmax = max(bval, bmax) 127 | return VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo) 128 | 129 | @staticmethod 130 | def median_cut_apply(histo, vbox): 131 | if not vbox.count: 132 | return (None, None) 133 | 134 | rw = vbox.r2 - vbox.r1 + 1 135 | gw = vbox.g2 - vbox.g1 + 1 136 | bw = vbox.b2 - vbox.b1 + 1 137 | maxw = max([rw, gw, bw]) 138 | # only one pixel, no split 139 | if vbox.count == 1: 140 | return (vbox.copy, None) 141 | # Find the partial sum arrays along the selected axis. 142 | total = 0 143 | sum_ = 0 144 | partialsum = {} 145 | lookaheadsum = {} 146 | do_cut_color = None 147 | if maxw == rw: 148 | do_cut_color = 'r' 149 | for i in range(vbox.r1, vbox.r2+1): 150 | sum_ = 0 151 | for j in range(vbox.g1, vbox.g2+1): 152 | for k in range(vbox.b1, vbox.b2+1): 153 | index = MMCQ.get_color_index(i, j, k) 154 | sum_ += histo.get(index, 0) 155 | total += sum_ 156 | partialsum[i] = total 157 | elif maxw == gw: 158 | do_cut_color = 'g' 159 | for i in range(vbox.g1, vbox.g2+1): 160 | sum_ = 0 161 | for j in range(vbox.r1, vbox.r2+1): 162 | for k in range(vbox.b1, vbox.b2+1): 163 | index = MMCQ.get_color_index(j, i, k) 164 | sum_ += histo.get(index, 0) 165 | total += sum_ 166 | partialsum[i] = total 167 | else: # maxw == bw 168 | do_cut_color = 'b' 169 | for i in range(vbox.b1, vbox.b2+1): 170 | sum_ = 0 171 | for j in range(vbox.r1, vbox.r2+1): 172 | for k in range(vbox.g1, vbox.g2+1): 173 | index = MMCQ.get_color_index(j, k, i) 174 | sum_ += histo.get(index, 0) 175 | total += sum_ 176 | partialsum[i] = total 177 | for i, d in partialsum.items(): 178 | lookaheadsum[i] = total - d 179 | 180 | # determine the cut planes 181 | dim1 = do_cut_color + '1' 182 | dim2 = do_cut_color + '2' 183 | dim1_val = getattr(vbox, dim1) 184 | dim2_val = getattr(vbox, dim2) 185 | for i in range(dim1_val, dim2_val+1): 186 | if partialsum[i] > (total / 2): 187 | vbox1 = vbox.copy 188 | vbox2 = vbox.copy 189 | left = i - dim1_val 190 | right = dim2_val - i 191 | if left <= right: 192 | d2 = min([dim2_val - 1, int(i + right / 2)]) 193 | else: 194 | d2 = max([dim1_val, int(i - 1 - left / 2)]) 195 | # avoid 0-count boxes 196 | while not partialsum.get(d2, False): 197 | d2 += 1 198 | count2 = lookaheadsum.get(d2) 199 | while not count2 and partialsum.get(d2-1, False): 200 | d2 -= 1 201 | count2 = lookaheadsum.get(d2) 202 | # set dimensions 203 | setattr(vbox1, dim2, d2) 204 | setattr(vbox2, dim1, getattr(vbox1, dim2) + 1) 205 | return (vbox1, vbox2) 206 | return (None, None) 207 | 208 | @staticmethod 209 | def quantize(pixels, max_color): 210 | """Quantize. 211 | 212 | :param pixels: a list of pixel in the form (r, g, b) 213 | :param max_color: max number of colors 214 | """ 215 | if not pixels: 216 | raise Exception('Empty pixels when quantize.') 217 | if max_color < 2 or max_color > 256: 218 | raise Exception('Wrong number of max colors when quantize.') 219 | 220 | histo = MMCQ.get_histo(pixels) 221 | 222 | # check that we aren't below maxcolors already 223 | if len(histo) <= max_color: 224 | # generate the new colors from the histo and return 225 | pass 226 | 227 | # get the beginning vbox from the colors 228 | vbox = MMCQ.vbox_from_pixels(pixels, histo) 229 | pq = PQueue(lambda x: x.count) 230 | pq.push(vbox) 231 | 232 | # inner function to do the iteration 233 | def iter_(lh, target): 234 | n_color = 1 235 | n_iter = 0 236 | while n_iter < MMCQ.MAX_ITERATION: 237 | vbox = lh.pop() 238 | if not vbox.count: # just put it back 239 | lh.push(vbox) 240 | n_iter += 1 241 | continue 242 | # do the cut 243 | vbox1, vbox2 = MMCQ.median_cut_apply(histo, vbox) 244 | if not vbox1: 245 | raise Exception("vbox1 not defined; shouldn't happen!") 246 | lh.push(vbox1) 247 | if vbox2: # vbox2 can be null 248 | lh.push(vbox2) 249 | n_color += 1 250 | if n_color >= target: 251 | return 252 | if n_iter > MMCQ.MAX_ITERATION: 253 | return 254 | n_iter += 1 255 | 256 | # first set of colors, sorted by population 257 | iter_(pq, MMCQ.FRACT_BY_POPULATIONS * max_color) 258 | 259 | # Re-sort by the product of pixel occupancy times the size in 260 | # color space. 261 | pq2 = PQueue(lambda x: x.count * x.volume) 262 | while pq.size(): 263 | pq2.push(pq.pop()) 264 | 265 | # next set - generate the median cuts using the (npix * vol) sorting. 266 | iter_(pq2, max_color - pq2.size()) 267 | 268 | # calculate the actual colors 269 | cmap = CMap() 270 | while pq2.size(): 271 | cmap.push(pq2.pop()) 272 | return cmap 273 | 274 | 275 | class VBox(object): 276 | """3d color space box""" 277 | def __init__(self, r1, r2, g1, g2, b1, b2, histo): 278 | self.r1 = r1 279 | self.r2 = r2 280 | self.g1 = g1 281 | self.g2 = g2 282 | self.b1 = b1 283 | self.b2 = b2 284 | self.histo = histo 285 | 286 | @cached_property 287 | def volume(self): 288 | sub_r = self.r2 - self.r1 289 | sub_g = self.g2 - self.g1 290 | sub_b = self.b2 - self.b1 291 | return (sub_r + 1) * (sub_g + 1) * (sub_b + 1) 292 | 293 | @property 294 | def copy(self): 295 | return VBox(self.r1, self.r2, self.g1, self.g2, 296 | self.b1, self.b2, self.histo) 297 | 298 | @cached_property 299 | def avg(self): 300 | ntot = 0 301 | mult = 1 << (8 - MMCQ.SIGBITS) 302 | r_sum = 0 303 | g_sum = 0 304 | b_sum = 0 305 | for i in range(self.r1, self.r2 + 1): 306 | for j in range(self.g1, self.g2 + 1): 307 | for k in range(self.b1, self.b2 + 1): 308 | histoindex = MMCQ.get_color_index(i, j, k) 309 | hval = self.histo.get(histoindex, 0) 310 | ntot += hval 311 | r_sum += hval * (i + 0.5) * mult 312 | g_sum += hval * (j + 0.5) * mult 313 | b_sum += hval * (k + 0.5) * mult 314 | 315 | if ntot: 316 | r_avg = int(r_sum / ntot) 317 | g_avg = int(g_sum / ntot) 318 | b_avg = int(b_sum / ntot) 319 | else: 320 | r_avg = int(mult * (self.r1 + self.r2 + 1) / 2) 321 | g_avg = int(mult * (self.g1 + self.g2 + 1) / 2) 322 | b_avg = int(mult * (self.b1 + self.b2 + 1) / 2) 323 | 324 | return r_avg, g_avg, b_avg 325 | 326 | def contains(self, pixel): 327 | rval = pixel[0] >> MMCQ.RSHIFT 328 | gval = pixel[1] >> MMCQ.RSHIFT 329 | bval = pixel[2] >> MMCQ.RSHIFT 330 | return all([ 331 | rval >= self.r1, 332 | rval <= self.r2, 333 | gval >= self.g1, 334 | gval <= self.g2, 335 | bval >= self.b1, 336 | bval <= self.b2, 337 | ]) 338 | 339 | @cached_property 340 | def count(self): 341 | npix = 0 342 | for i in range(self.r1, self.r2 + 1): 343 | for j in range(self.g1, self.g2 + 1): 344 | for k in range(self.b1, self.b2 + 1): 345 | index = MMCQ.get_color_index(i, j, k) 346 | npix += self.histo.get(index, 0) 347 | return npix 348 | 349 | 350 | class CMap(object): 351 | """Color map""" 352 | def __init__(self): 353 | self.vboxes = PQueue(lambda x: x['vbox'].count * x['vbox'].volume) 354 | 355 | @property 356 | def palette(self): 357 | return self.vboxes.map(lambda x: x['color']) 358 | 359 | def push(self, vbox): 360 | self.vboxes.push({ 361 | 'vbox': vbox, 362 | 'color': vbox.avg, 363 | }) 364 | 365 | def size(self): 366 | return self.vboxes.size() 367 | 368 | def nearest(self, color): 369 | d1 = None 370 | p_color = None 371 | for i in range(self.vboxes.size()): 372 | vbox = self.vboxes.peek(i) 373 | d2 = math.sqrt( 374 | math.pow(color[0] - vbox['color'][0], 2) + 375 | math.pow(color[1] - vbox['color'][1], 2) + 376 | math.pow(color[2] - vbox['color'][2], 2) 377 | ) 378 | if d1 is None or d2 < d1: 379 | d1 = d2 380 | p_color = vbox['color'] 381 | return p_color 382 | 383 | def map(self, color): 384 | for i in range(self.vboxes.size()): 385 | vbox = self.vboxes.peek(i) 386 | if vbox['vbox'].contains(color): 387 | return vbox['color'] 388 | return self.nearest(color) 389 | 390 | 391 | class PQueue(object): 392 | """Simple priority queue.""" 393 | def __init__(self, sort_key): 394 | self.sort_key = sort_key 395 | self.contents = [] 396 | self._sorted = False 397 | 398 | def sort(self): 399 | self.contents.sort(key=self.sort_key) 400 | self._sorted = True 401 | 402 | def push(self, o): 403 | self.contents.append(o) 404 | self._sorted = False 405 | 406 | def peek(self, index=None): 407 | if not self._sorted: 408 | self.sort() 409 | if index is None: 410 | index = len(self.contents) - 1 411 | return self.contents[index] 412 | 413 | def pop(self): 414 | if not self._sorted: 415 | self.sort() 416 | return self.contents.pop() 417 | 418 | def size(self): 419 | return len(self.contents) 420 | 421 | def map(self, f): 422 | return list(map(f, self.contents)) 423 | --------------------------------------------------------------------------------