From 43c498999315334ca0c0da74504f53657ca12119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=9A?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=B2?= Date: Thu, 24 Dec 2020 00:38:20 +0300 Subject: [PATCH 1/5] gitignore change --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 96fdcea..c8cd558 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist/ node_modules/ .cache/ .vscode/ +.idea +.DS_Store From 7bcdd4cf7e0c21cd3e97eeace02b294f98f23046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=9A?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=B2?= Date: Thu, 24 Dec 2020 00:39:25 +0300 Subject: [PATCH 2/5] 1.3.0: cython support for speed boost --- colorgram/__init__.py | 2 +- colorgram/colorgram.py | 126 +++++++----------------------- colorgram/utils_c.pyx | 173 +++++++++++++++++++++++++++++++++++++++++ colorgram/utils_p.py | 97 +++++++++++++++++++++++ readme.rst | 44 +++++++++++ setup.py | 17 +++- 6 files changed, 360 insertions(+), 99 deletions(-) create mode 100644 colorgram/utils_c.pyx create mode 100644 colorgram/utils_p.py diff --git a/colorgram/__init__.py b/colorgram/__init__.py index 3bdb902..fc8ce95 100644 --- a/colorgram/__init__.py +++ b/colorgram/__init__.py @@ -5,4 +5,4 @@ from __future__ import absolute_import -from .colorgram import extract, Color +from .colorgram import * diff --git a/colorgram/colorgram.py b/colorgram/colorgram.py index 1782c06..15321e5 100644 --- a/colorgram/colorgram.py +++ b/colorgram/colorgram.py @@ -2,21 +2,38 @@ from __future__ import unicode_literals from __future__ import division +from __future__ import absolute_import + +import logging -import array from collections import namedtuple from PIL import Image -import sys -if sys.version_info[0] <= 2: - range = xrange - ARRAY_DATATYPE = b'l' -else: - ARRAY_DATATYPE = 'l' + +logger = logging.getLogger(__name__) + + +try: + import cython + import pyximport + import sys + + pyximport.install(language_level=sys.version_info[0]) + import colorgram.utils_c as utils + logger.debug('c-boosted version will be used') +except ImportError: + import colorgram.utils_p as utils + logger.debug('pure python version will be used') + + +__all__ = [ + 'Color', 'extract' +] Rgb = namedtuple('Rgb', ('r', 'g', 'b')) Hsl = namedtuple('Hsl', ('h', 's', 'l')) + class Color(object): def __init__(self, r, g, b, proportion): self.rgb = Rgb(r, g, b) @@ -31,68 +48,21 @@ def hsl(self): try: return self._hsl except AttributeError: - self._hsl = Hsl(*hsl(*self.rgb)) + self._hsl = Hsl(*utils.hsl(*self.rgb)) return self._hsl + def extract(f, number_of_colors): image = f if isinstance(f, Image.Image) else Image.open(f) if image.mode not in ('RGB', 'RGBA', 'RGBa'): image = image.convert('RGB') - - samples = sample(image) + + pixels = list(image.getdata()) + samples = utils.sample(pixels) used = pick_used(samples) used.sort(key=lambda x: x[0], reverse=True) return get_colors(samples, used, number_of_colors) -def sample(image): - top_two_bits = 0b11000000 - - sides = 1 << 2 # Left by the number of bits used. - cubes = sides ** 7 - - samples = array.array(ARRAY_DATATYPE, (0 for _ in range(cubes))) - width, height = image.size - - pixels = image.load() - for y in range(height): - for x in range(width): - # Pack the top two bits of all 6 values into 12 bits. - # 0bYYhhllrrggbb - luminance, hue, luminosity, red, green, blue. - - r, g, b = pixels[x, y][:3] - h, s, l = hsl(r, g, b) - # Standard constants for converting RGB to relative luminance. - Y = int(r * 0.2126 + g * 0.7152 + b * 0.0722) - - # Everything's shifted into place from the top two - # bits' original position - that is, bits 7-8. - packed = (Y & top_two_bits) << 4 - packed |= (h & top_two_bits) << 2 - packed |= (l & top_two_bits) << 0 - - # Due to a bug in the original colorgram.js, RGB isn't included. - # The original author tries using negative bit shifts, while in - # fact JavaScript has the stupidest possible behavior for those. - # By uncommenting these lines, "intended" behavior can be - # restored, but in order to keep result compatibility with the - # original the "error" exists here too. Add back in if it is - # ever fixed in colorgram.js. - - # packed |= (r & top_two_bits) >> 2 - # packed |= (g & top_two_bits) >> 4 - # packed |= (b & top_two_bits) >> 6 - # print "Pixel #{}".format(str(y * width + x)) - # print "h: {}, s: {}, l: {}".format(str(h), str(s), str(l)) - # print "R: {}, G: {}, B: {}".format(str(r), str(g), str(b)) - # print "Y: {}".format(str(Y)) - # print "Packed: {}, binary: {}".format(str(packed), bin(packed)[2:]) - # print - packed *= 4 - samples[packed] += r - samples[packed + 1] += g - samples[packed + 2] += b - samples[packed + 3] += 1 - return samples def pick_used(samples): used = [] @@ -102,6 +72,7 @@ def pick_used(samples): used.append((count, i)) return used + def get_colors(samples, used, number_of_colors): pixels = 0 colors = [] @@ -122,43 +93,6 @@ def get_colors(samples, used, number_of_colors): color.proportion /= pixels return colors -def hsl(r, g, b): - # This looks stupid, but it's way faster than min() and max(). - if r > g: - if b > r: - most, least = b, g - elif b > g: - most, least = r, g - else: - most, least = r, b - else: - if b > g: - most, least = b, r - elif b > r: - most, least = g, r - else: - most, least = g, b - - l = (most + least) >> 1 - - if most == least: - h = s = 0 - else: - diff = most - least - if l > 127: - s = diff * 255 // (510 - most - least) - else: - s = diff * 255 // (most + least) - - if most == r: - h = (g - b) * 255 // diff + (1530 if g < b else 0) - elif most == g: - h = (b - r) * 255 // diff + 510 - else: - h = (r - g) * 255 // diff + 1020 - h //= 6 - - return h, s, l # Useful snippet for testing values: # print "Pixel #{}".format(str(y * width + x)) diff --git a/colorgram/utils_c.pyx b/colorgram/utils_c.pyx new file mode 100644 index 0000000..a3ff05d --- /dev/null +++ b/colorgram/utils_c.pyx @@ -0,0 +1,173 @@ +import cython + +# cython: infer_types=True +# cython: boundscheck=False +# cython: wraparound=False +# cython: cdivision=True +# cython: nonecheck=False +# cython: language_level=3 + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.nonecheck(False) +@cython.cdivision(True) +@cython.optimize.unpack_method_calls(True) +cdef int rgb_to_pack(int r, int g, int b): + # declare variables + cdef int most = 0 + cdef int least = 0 + cdef int diff = 0 + cdef int h = 0 + cdef int s = 0 + cdef int l = 0 + cdef int Y = 0 + + cdef int top_two_bits = 0b11000000 + cdef int result = 0 + + # extract HSL+Y - This looks stupid, but it's way faster than min() and max(). + if r > g: + if b > r: + most, least = b, g + elif b > g: + most, least = r, g + else: + most, least = r, b + else: + if b > g: + most, least = b, r + elif b > r: + most, least = g, r + else: + most, least = g, b + + l = (most + least) >> 1 + + if most == least: + h = s = 0 + else: + diff = most - least + if l > 127: + s = diff * 255 // (510 - most - least) + else: + s = diff * 255 // (most + least) + + if most == r: + h = (g - b) * 255 // diff + (1530 if g < b else 0) + elif most == g: + h = (b - r) * 255 // diff + 510 + else: + h = (r - g) * 255 // diff + 1020 + h //= 6 + + Y = int(r * 0.2126 + g * 0.7152 + b * 0.0722) + + # return packed info + # result = (Y & top_two_bits) << 4 + # result |= (h & top_two_bits) << 2 + # result |= (l & top_two_bits) << 0 + # + # result *= 4 + + return (((Y & top_two_bits) << 4) + ((h & top_two_bits) << 2) + (l & top_two_bits)) * 4 + # return result + +@cython.wraparound(False) +@cython.boundscheck(False) +cpdef list sample(list pixels): + cdef int top_two_bits = 0b11000000 + + cdef int sides = 1 << 2 # 4 - Left by the number of bits used. + + cdef int cubes = sides ** 7 + + cdef list samples = [0] * cubes + + for item in pixels: + r = item[0] + g = item[1] + b = item[2] + # Pack the top two bits of all 6 values into 12 bits. + # 0bYYhhllrrggbb - luminance, hue, luminosity, red, green, blue. + + # Standard constants for converting RGB to relative luminance. + # Y = int(r * 0.2126 + g * 0.7152 + b * 0.0722) + + # Everything's shifted into place from the top two + # bits' original position - that is, bits 7-8. + # packed = (Y & top_two_bits) << 4 + # packed |= (h & top_two_bits) << 2 + # packed |= (l & top_two_bits) << 0 + + # Due to a bug in the original colorgram.js, RGB isn't included. + # The original author tries using negative bit shifts, while in + # fact JavaScript has the stupidest possible behavior for those. + # By uncommenting these lines, "intended" behavior can be + # restored, but in order to keep result compatibility with the + # original the "error" exists here too. Add back in if it is + # ever fixed in colorgram.js. + + # packed |= (r & top_two_bits) >> 2 + # packed |= (g & top_two_bits) >> 4 + # packed |= (b & top_two_bits) >> 6 + + packed = rgb_to_pack(r,g,b) + + # packed = 0 + samples[packed] += r + samples[packed + 1] += g + samples[packed + 2] += b + samples[packed + 3] += 1 + return samples + + +@cython.wraparound(False) +@cython.boundscheck(False) +@cython.nonecheck(False) +@cython.cdivision(True) +@cython.optimize.unpack_method_calls(True) +cpdef (int, int, int) hsl(int r, int g, int b): + # declare variables + cdef int most = 0 + cdef int least = 0 + cdef int diff = 0 + cdef int h = 0 + cdef int s = 0 + cdef int l = 0 + + # This looks stupid, but it's way faster than min() and max(). + if r > g: + if b > r: + most, least = b, g + elif b > g: + most, least = r, g + else: + most, least = r, b + else: + if b > g: + most, least = b, r + elif b > r: + most, least = g, r + else: + most, least = g, b + + l = (most + least) >> 1 + + if most == least: + h = s = 0 + else: + diff = most - least + if l > 127: + s = diff * 255 // (510 - most - least) + else: + s = diff * 255 // (most + least) + + if most == r: + h = (g - b) * 255 // diff + (1530 if g < b else 0) + elif most == g: + h = (b - r) * 255 // diff + 510 + else: + h = (r - g) * 255 // diff + 1020 + h //= 6 + + return h, s, l diff --git a/colorgram/utils_p.py b/colorgram/utils_p.py new file mode 100644 index 0000000..37cfd09 --- /dev/null +++ b/colorgram/utils_p.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- + +import array +import sys + +if sys.version_info[0] <= 2: + range = xrange + ARRAY_DATATYPE = b'l' +else: + ARRAY_DATATYPE = 'l' + + +def sample(pixels): + top_two_bits = 0b11000000 + + sides = 1 << 2 # Left by the number of bits used. + cubes = sides ** 7 + + samples = array.array(ARRAY_DATATYPE, (0 for _ in range(cubes))) + + for pixel in pixels: + # Pack the top two bits of all 6 values into 12 bits. + # 0bYYhhllrrggbb - luminance, hue, luminosity, red, green, blue. + + r, g, b = pixel[0], pixel[1], pixel[2] + h, s, l = hsl(r, g, b) + # Standard constants for converting RGB to relative luminance. + Y = int(r * 0.2126 + g * 0.7152 + b * 0.0722) + + # Everything's shifted into place from the top two + # bits' original position - that is, bits 7-8. + packed = (Y & top_two_bits) << 4 + packed |= (h & top_two_bits) << 2 + packed |= (l & top_two_bits) << 0 + + # Due to a bug in the original colorgram.js, RGB isn't included. + # The original author tries using negative bit shifts, while in + # fact JavaScript has the stupidest possible behavior for those. + # By uncommenting these lines, "intended" behavior can be + # restored, but in order to keep result compatibility with the + # original the "error" exists here too. Add back in if it is + # ever fixed in colorgram.js. + + # packed |= (r & top_two_bits) >> 2 + # packed |= (g & top_two_bits) >> 4 + # packed |= (b & top_two_bits) >> 6 + # print "Pixel #{}".format(str(y * width + x)) + # print "h: {}, s: {}, l: {}".format(str(h), str(s), str(l)) + # print "R: {}, G: {}, B: {}".format(str(r), str(g), str(b)) + # print "Y: {}".format(str(Y)) + # print "Packed: {}, binary: {}".format(str(packed), bin(packed)[2:]) + # print + packed *= 4 + samples[packed] += r + samples[packed + 1] += g + samples[packed + 2] += b + samples[packed + 3] += 1 + return samples + + +def hsl(r, g, b): + # This looks stupid, but it's way faster than min() and max(). + if r > g: + if b > r: + most, least = b, g + elif b > g: + most, least = r, g + else: + most, least = r, b + else: + if b > g: + most, least = b, r + elif b > r: + most, least = g, r + else: + most, least = g, b + + l = (most + least) >> 1 + + if most == least: + h = s = 0 + else: + diff = most - least + if l > 127: + s = diff * 255 // (510 - most - least) + else: + s = diff * 255 // (most + least) + + if most == r: + h = (g - b) * 255 // diff + (1530 if g < b else 0) + elif most == g: + h = (b - r) * 255 // diff + 510 + else: + h = (r - g) * 255 // diff + 1020 + h //= 6 + + return h, s, l diff --git a/readme.rst b/readme.rst index d2b4722..b099221 100644 --- a/readme.rst +++ b/readme.rst @@ -73,6 +73,50 @@ Something the original library lets you do is sort the colors you get by HSL. In # or... sorted(colors, key=lambda c: c.hsl.h) +Performance +----------- +Performance can be boosted with usage of Cython `Cython `__ dependency. When Cython discovered would automatically switch on more efficient C-version of project. + +**Benchmark conditions:** + +* machine: Mac OS Catalina, Intel Core i5 3.4 GHz +* image: `tests/test.png` + +**Benchmark code:** + +.. code:: python + + import timeit + import statistics # Python 2.7: pip install statistics + + _setup = ''' + from PIL import Image + num_colors = 6 + img = Image.open('data/test.png') + img.load() + ''' + + _code = ''' + import colorgram + colorgram.extract(img, num_colors) + ''' + number = 20 + repeats = 10 + measures = timeit.repeat(setup=_setup, stmt=_code, number=number, repeat=repeats) + + _mean = statistics.mean(measures) / number + _stdev = statistics.stdev(measures) / number + + print('results: %0.6f (+/- %0.6f) sec.' % (_mean, _stdev)) + + +**Benchmark results:** + +* results: 0.402446 (+/- 0.003126) sec. (Python 2.7.6, Pillow 6.2.2) +* results: 0.081205 (+/- 0.003234) sec. (Python 2.7.6, Pillow 6.2.2, Cython) ~ 4.95 faster +* results: 0.553765 (+/- 0.002030) sec. (Python 3.6.8, Pillow 8.0.1) +* results: 0.108687 (+/- 0.011445) sec. (Python 3.6.8, Pillow 8.0.1, Cython) ~ 5.09 faster + Contact ------- diff --git a/setup.py b/setup.py index ad5fe95..41c92c0 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,11 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import os + from setuptools import setup -VERSION = '1.2.0' +VERSION = '1.3.0' REQUIREMENTS = [ "pillow >= 3.3.1" @@ -12,11 +14,18 @@ with open("readme.rst", 'r') as f: long_description = f.read() + +def get_packages(package): + """ Return root package and all sub-packages. """ + return [dirpath + for dirpath, dirnames, filenames in os.walk(package) + if os.path.exists(os.path.join(dirpath, '__init__.py'))] + setup( name="colorgram.py", version=VERSION, install_requires=REQUIREMENTS, - packages=['colorgram'], + packages=get_packages('colorgram'), author="Samuel Messner", author_email="powpowd@gmail.com", url="https://github.com/obskyr/colorgram.py", @@ -37,6 +46,10 @@ 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities" ] From 1437a21f7564c2cd61f28708111f45ef55bfbe4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=9A?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=B2?= Date: Thu, 24 Dec 2020 00:39:41 +0300 Subject: [PATCH 3/5] benchmark --- tests/benchmark.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/benchmark.py diff --git a/tests/benchmark.py b/tests/benchmark.py new file mode 100644 index 0000000..f76bb74 --- /dev/null +++ b/tests/benchmark.py @@ -0,0 +1,22 @@ +import timeit +import statistics + +_setup = ''' +from PIL import Image +num_colors = 6 +img = Image.open('data/test.png') +img.load() +''' + +_code = ''' +import colorgram +colorgram.extract(img, num_colors) +''' +number = 20 +repeats = 10 +measures = timeit.repeat(setup=_setup, stmt=_code, number=number, repeat=repeats) + +_mean = statistics.mean(measures) / number +_stdev = statistics.stdev(measures) / number + +print('results: %0.6f (+/- %0.6f) sec.' % (_mean, _stdev)) \ No newline at end of file From 72681daa053dce28b96c4cf79e369e9701437694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=9A?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=B2?= Date: Thu, 24 Dec 2020 00:40:49 +0300 Subject: [PATCH 4/5] readme --- readme.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.rst b/readme.rst index b099221..ce7e0ff 100644 --- a/readme.rst +++ b/readme.rst @@ -75,7 +75,7 @@ Something the original library lets you do is sort the colors you get by HSL. In Performance ----------- -Performance can be boosted with usage of Cython `Cython `__ dependency. When Cython discovered would automatically switch on more efficient C-version of project. +Performance can be boosted with usage of `Cython `__ dependency. When Cython discovered would automatically switch on more efficient C-version of project. **Benchmark conditions:** From 17052c008c53721449e6d7d463eb7753f0cfef29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=9A?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=B2?= Date: Fri, 25 Dec 2020 01:35:02 +0300 Subject: [PATCH 5/5] fix sdist error --- MANIFEST.in | 2 ++ setup.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 9bfe1aa..2dc937b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,4 @@ include LICENSE include readme.rst +global-include *.pyx +global-include *.pxd diff --git a/setup.py b/setup.py index 41c92c0..45c1704 100644 --- a/setup.py +++ b/setup.py @@ -26,6 +26,8 @@ def get_packages(package): version=VERSION, install_requires=REQUIREMENTS, packages=get_packages('colorgram'), + package_data={'colorgram': ['colorgram/utils_c.pyx']}, + include_package_data=True, author="Samuel Messner", author_email="powpowd@gmail.com", url="https://github.com/obskyr/colorgram.py",