From aafb916a305059992b0c004a879a17ae273715ba Mon Sep 17 00:00:00 2001 From: Austin Pua Date: Fri, 15 Nov 2019 12:22:56 +0800 Subject: [PATCH 1/2] Improve color provider --- faker/providers/color/__init__.py | 16 ++ faker/providers/color/color.py | 276 ++++++++++++++++++++++++++++++ tests/providers/test_color.py | 209 ++++++++++++++++++++++ 3 files changed, 501 insertions(+) create mode 100644 faker/providers/color/color.py diff --git a/faker/providers/color/__init__.py b/faker/providers/color/__init__.py index 92bb669d0c..4d039dc653 100644 --- a/faker/providers/color/__init__.py +++ b/faker/providers/color/__init__.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from collections import OrderedDict +from .color import RandomColor from .. import BaseProvider localized = True @@ -180,3 +181,18 @@ def rgb_color(self): def rgb_css_color(self): return 'rgb(%s)' % ','.join( map(str, (self.random_int(0, 255) for _ in range(3)))) + + def color(self, hue=None, luminosity=None, color_format='hex'): + """ + Creates a color in specified format + + :param hue: monochrome, red, orange, yellow, green, blue, purple, pink, a number + from 0 to 360, or a tuple/list of 2 numbers from 0 to 360 + :param luminosity: bright, dark, light, or random + :param color_format: hsv, hsl, rgb, or hex with hex being default + :return: color in the specified format + """ + + return RandomColor(self.generator).generate( + hue=hue, luminosity=luminosity, color_format=color_format, + ) diff --git a/faker/providers/color/color.py b/faker/providers/color/color.py new file mode 100644 index 0000000000..e4fa839325 --- /dev/null +++ b/faker/providers/color/color.py @@ -0,0 +1,276 @@ +""" +Code adapted from: +- https://github.com/davidmerfield/randomColor (CC0) +- https://github.com/kevinwuhoo/randomcolor-py (MIT License) + +Additional reference from: +- https://en.wikipedia.org/wiki/HSL_and_HSV +""" + +import colorsys +import math +import random +import sys + +import six + + +COLOR_MAP = { + 'monochrome': { + 'hue_range': [0, 0], + 'lower_bounds': [ + [0, 0], [100, 0], + ], + }, + 'red': { + 'hue_range': [-26, 18], + 'lower_bounds': [ + [20, 100], [30, 92], [40, 89], + [50, 85], [60, 78], [70, 70], + [80, 60], [90, 55], [100, 50], + ], + }, + 'orange': { + 'hue_range': [19, 46], + 'lower_bounds': [ + [20, 100], [30, 93], [40, 88], [50, 86], + [60, 85], [70, 70], [100, 70], + ], + }, + 'yellow': { + 'hue_range': [47, 62], + 'lower_bounds': [ + [25, 100], [40, 94], [50, 89], [60, 86], + [70, 84], [80, 82], [90, 80], [100, 75], + ], + }, + 'green': { + 'hue_range': [63, 178], + 'lower_bounds': [ + [30, 100], [40, 90], [50, 85], [60, 81], + [70, 74], [80, 64], [90, 50], [100, 40], + ], + }, + 'blue': { + 'hue_range': [179, 257], + 'lower_bounds': [ + [20, 100], [30, 86], [40, 80], + [50, 74], [60, 60], [70, 52], + [80, 44], [90, 39], [100, 35], + ], + }, + 'purple': { + 'hue_range': [258, 282], + 'lower_bounds': [ + [20, 100], [30, 87], [40, 79], + [50, 70], [60, 65], [70, 59], + [80, 52], [90, 45], [100, 42], + ], + }, + 'pink': { + 'hue_range': [283, 334], + 'lower_bounds': [ + [20, 100], [30, 90], [40, 86], [60, 84], + [80, 80], [90, 75], [100, 73], + ], + }, +} + + +class RandomColor(object): + + def __init__(self, generator=None, seed=None): + self.colormap = COLOR_MAP + + # Option to specify a seed was not removed so this class + # can still be tested independently w/o generators + if generator: + self.random = generator.random + else: + self.seed = seed if seed else random.randint(0, sys.maxsize) + self.random = random.Random(self.seed) + + for color_name, color_attrs in self.colormap.items(): + lower_bounds = color_attrs['lower_bounds'] + s_min = lower_bounds[0][0] + s_max = lower_bounds[len(lower_bounds) - 1][0] + + b_min = lower_bounds[len(lower_bounds) - 1][1] + b_max = lower_bounds[0][1] + + self.colormap[color_name]['saturation_range'] = [s_min, s_max] + self.colormap[color_name]['brightness_range'] = [b_min, b_max] + + def generate(self, hue=None, luminosity=None, color_format='hex'): + # First we pick a hue (H) + h = self.pick_hue(hue) + + # Then use H to determine saturation (S) + s = self.pick_saturation(h, hue, luminosity) + + # Then use S and H to determine brightness (B). + b = self.pick_brightness(h, s, luminosity) + + # Then we return the HSB color in the desired format + return self.set_format([h, s, b], color_format) + + def pick_hue(self, hue): + hue_range = self.get_hue_range(hue) + hue = self.random_within(hue_range) + + # Instead of storing red as two seperate ranges, + # we group them, using negative numbers + if hue < 0: + hue += 360 + + return hue + + def pick_saturation(self, hue, hue_name, luminosity): + if luminosity == 'random': + return self.random_within([0, 100]) + + if hue_name == 'monochrome': + return 0 + + saturation_range = self.get_saturation_range(hue) + + s_min = saturation_range[0] + s_max = saturation_range[1] + + if luminosity == 'bright': + s_min = 55 + elif luminosity == 'dark': + s_min = s_max - 10 + elif luminosity == 'light': + s_max = 55 + + return self.random_within([s_min, s_max]) + + def pick_brightness(self, h, s, luminosity): + b_min = self.get_minimum_brightness(h, s) + b_max = 100 + + if luminosity == 'dark': + b_max = b_min + 20 + elif luminosity == 'light': + b_min = (b_max + b_min) / 2 + elif luminosity == 'random': + b_min = 0 + b_max = 100 + + return self.random_within([b_min, b_max]) + + def set_format(self, hsv, color_format): + if color_format == 'hsv': + color = 'hsv({}, {}, {})'.format(*hsv) + + elif color_format == 'hsl': + hsl = self.hsv_to_hsl(hsv) + color = 'hsl({}, {}, {})'.format(*hsl) + + elif color_format == 'rgb': + rgb = self.hsv_to_rgb(hsv) + color = 'rgb({}, {}, {})'.format(*rgb) + + else: + rgb = self.hsv_to_rgb(hsv) + color = '#{:02x}{:02x}{:02x}'.format(*rgb) + + return color + + def get_minimum_brightness(self, h, s): + lower_bounds = self.get_color_info(h)['lower_bounds'] + + for i in range(len(lower_bounds) - 1): + s1 = lower_bounds[i][0] + v1 = lower_bounds[i][1] + + s2 = lower_bounds[i + 1][0] + v2 = lower_bounds[i + 1][1] + + if s1 <= s <= s2: + m = (v2 - v1) / (s2 - s1) + b = v1 - m * s1 + + return m * s + b + + return 0 + + def get_hue_range(self, color_input): + if isinstance(color_input, (int, float)) and 0 <= color_input <= 360: + color_input = int(color_input) + return [color_input, color_input] + + elif isinstance(color_input, six.string_types) and color_input in self.colormap: + return self.colormap[color_input]['hue_range'] + + try: + v1, v2 = color_input + v1 = int(v1) + v2 = int(v2) + except (ValueError, TypeError): + return [0, 360] + else: + if v2 < v1: + v1, v2 = v2, v1 + if v1 < 0: + v1 = 0 + if v2 > 360: + v2 = 360 + return [v1, v2] + + def get_saturation_range(self, hue): + return self.get_color_info(hue)['saturation_range'] + + def get_color_info(self, hue): + # Maps red colors to make picking hue easier + if 334 <= hue <= 360: + hue -= 360 + + for color_name, color in self.colormap.items(): + if color['hue_range'][0] <= hue <= color['hue_range'][1]: + return self.colormap[color_name] + else: + raise ValueError('Value of hue is invalid.') + + def random_within(self, r): + return self.random.randint(int(r[0]), int(r[1])) + + @classmethod + def hsv_to_rgb(cls, hsv): + """ + Converts HSV to RGB + + :param hsv: 3-tuple of h, s, and v values + :return: 3-tuple of r, g, and b values + """ + h, s, v = hsv + h = 1 if h == 0 else h + h = 359 if h == 360 else h + + h = float(h)/360 + s = float(s)/100 + v = float(v)/100 + + rgb = colorsys.hsv_to_rgb(h, s, v) + return (int(c * 255) for c in rgb) + + @classmethod + def hsv_to_hsl(cls, hsv): + """ + Converts HSV to HSL + + :param hsv: 3-tuple of h, s, and v values + :return: 3-tuple of h, s, and l values + """ + h, s, v = hsv + + s = float(s)/100 + v = float(v)/100 + l = 0.5 * v * (2 - s) # noqa: E741 + + if l in [0, 1]: + s = 0 + else: + s = v * s / (1 - math.fabs(2 * l - 1)) + return (int(c) for c in [h, s * 100, l * 100]) diff --git a/tests/providers/test_color.py b/tests/providers/test_color.py index 24f00314b9..8593143c6d 100644 --- a/tests/providers/test_color.py +++ b/tests/providers/test_color.py @@ -1,8 +1,11 @@ import unittest +import re from re import search from faker import Faker +from faker.providers.color import RandomColor from faker.providers.color.hy_AM import Provider as HyAmProvider +import six from six import string_types @@ -45,6 +48,212 @@ def test_rgb_css_color(self): assert maxval <= 255 assert minval >= 0 + def test_color(self): + baseline_random_color = RandomColor(seed=4761) + expected = [baseline_random_color.generate() for _ in range(10000)] + + # The `color` provider method should behave like the `generate` + # method of a standalone RandomColor instance for a given seed + self.factory.seed(4761) + colors = [self.factory.color() for _ in range(10000)] + assert colors == expected + + +class TestRandomColor(unittest.TestCase): + + seed = 4761 + hsv_color_pattern = re.compile( + r'^hsv\(' + r'(?P\d|[1-9]\d|[1-3]\d{2}), ' + r'(?P\d|[1-9]\d|100), ' + r'(?P\d|[1-9]\d|100)\)$', + ) + hsl_color_pattern = re.compile( + r'^hsl\(' + r'(?P\d|[1-9]\d|[1-3]\d{2}), ' + r'(?P\d|[1-9]\d|[1-3]\d{2}), ' + r'(?P\d|[1-9]\d|[1-3]\d{2})\)$', + ) + rgb_color_pattern = re.compile( + r'^rgb\(' + r'(?P\d|[1-9]\d|[1-3]\d{2}), ' + r'(?P\d|[1-9]\d|[1-3]\d{2}), ' + r'(?P\d|[1-9]\d|[1-3]\d{2})\)$', + ) + hex_color_pattern = re.compile(r'^#[0-9a-f]{6}$') + + def setUp(self): + self.random_color = RandomColor(seed=self.seed) + + def test_color_format_hsv(self): + for _ in range(10000): + hsv_color = self.random_color.generate(color_format='hsv') + match = self.hsv_color_pattern.match(hsv_color) + assert match + groupdict = match.groupdict() + assert 0 <= int(groupdict['h']) <= 360 + assert 0 <= int(groupdict['s']) <= 100 + assert 0 <= int(groupdict['v']) <= 100 + + def test_color_format_hsl(self): + for _ in range(10000): + hsl_color = self.random_color.generate(color_format='hsl') + match = self.hsl_color_pattern.match(hsl_color) + assert match + groupdict = match.groupdict() + assert 0 <= int(groupdict['h']) <= 360 + assert 0 <= int(groupdict['s']) <= 100 + assert 0 <= int(groupdict['l']) <= 100 + + def test_color_format_rgb(self): + for _ in range(10000): + rgb_color = self.random_color.generate(color_format='rgb') + match = self.rgb_color_pattern.match(rgb_color) + assert match + groupdict = match.groupdict() + assert 0 <= int(groupdict['r']) <= 255 + assert 0 <= int(groupdict['g']) <= 255 + assert 0 <= int(groupdict['b']) <= 255 + + def test_color_format_hex(self): + for _ in range(10000): + hex_color = self.random_color.generate(color_format='hex') + assert self.hex_color_pattern.match(hex_color) + + def test_color_format_unspecified(self): + for _ in range(10000): + color = self.random_color.generate() + assert self.hex_color_pattern.match(color) + + def test_hue_word(self): + if six.PY2: + expected = ['#f2f2f2', '#6b6b6b', '#939393', '#5e5e5e', '#aaaaaa'] + else: + expected = ['#cecece', '#ededed', '#efefef', '#bcbcbc', '#777777'] + colors = [self.random_color.generate(hue='monochrome') for _ in range(5)] + assert colors == expected + + if six.PY2: + expected = ['#c46542', '#d8495f', '#c42c09', '#dd4b68', '#c6135e'] + else: + expected = ['#ef0b31', '#f2b7ab', '#f74c55', '#a53822', '#8e3712'] + colors = [self.random_color.generate(hue='red') for _ in range(5)] + assert colors == expected + + if six.PY2: + expected = ['#fcda9f', '#ffa566', '#b55609', '#c9761e', '#fcd9c7'] + else: + expected = ['#f98313', '#ddb77e', '#f9c413', '#f4ce81', '#ddae71'] + colors = [self.random_color.generate(hue='orange') for _ in range(5)] + assert colors == expected + + if six.PY2: + expected = ['#f2f75b', '#d8cb38', '#efe3a5', '#dbc053', '#eae096'] + else: + expected = ['#dbe04e', '#efc621', '#fff65b', '#ceaf27', '#fcf9ae'] + colors = [self.random_color.generate(hue='yellow') for _ in range(5)] + assert colors == expected + + if six.PY2: + expected = ['#54d387', '#b1d15c', '#c0f78a', '#27b278', '#1bc43a'] + else: + expected = ['#05876f', '#57e095', '#50ceaa', '#e4f7a0', '#698909'] + colors = [self.random_color.generate(hue='green') for _ in range(5)] + assert colors == expected + + if six.PY2: + expected = ['#0b577c', '#7ad3d3', '#4884ce', '#bae8f2', '#79cafc'] + else: + expected = ['#2b839b', '#a4d3e8', '#3d2caa', '#3859a0', '#52349e'] + colors = [self.random_color.generate(hue='blue') for _ in range(5)] + assert colors == expected + + if six.PY2: + expected = ['#d2a7e5', '#a519fc', '#8b0ece', '#b17fe2', '#a949dd'] + else: + expected = ['#a074e8', '#6122bf', '#9f76cc', '#250570', '#3c1599'] + colors = [self.random_color.generate(hue='purple') for _ in range(5)] + assert colors == expected + + if six.PY2: + expected = ['#f453c4', '#db81ac', '#f99fc9', '#e23fff', '#bb68cc'] + else: + expected = ['#c605c6', '#fcc4ec', '#d979f7', '#ce108c', '#d3289d'] + colors = [self.random_color.generate(hue='pink') for _ in range(5)] + assert colors == expected + + def test_hue_tuple_beyond_limits(self): + baseline_random_color = RandomColor(seed=self.seed) + expected = [baseline_random_color.generate(hue=[0, 360]) for _ in range(1000)] + + # Using a tuple with values not between 0 and 360 should yield the same results + # as using a tuple with clamped values for a given seed + colors = [self.random_color.generate(hue=[-100, 4500]) for _ in range(1000)] + assert colors == expected + + def test_hue_tuple_inverted_values(self): + baseline_random_color = RandomColor(seed=self.seed) + expected = [baseline_random_color.generate(hue=[45, 75]) for _ in range(1000)] + + # Using a tuple with inverted values should yield the same results + # as using the correctly ordered tuple for a given seed + colors = [self.random_color.generate(hue=[75, 45]) for _ in range(1000)] + assert colors == expected + + def test_hue_invalid(self): + baseline_random_color = RandomColor(seed=self.seed) + expected = [baseline_random_color.generate() for _ in range(1000)] + + # Using any invalid value should yield the same results + # as not specifying a hue value for a given seed + invalid_values = [ + 'invalid value', # Unsupported string + [1, 2, 3], # List with incorrect number of elements of valid data types + ['ab', 1], # List with correct number of elements with invalid data types + self, # Any other garbage + ] + + for invalid_value in invalid_values: + random_color = RandomColor(seed=self.seed) + colors = [random_color.generate(hue=invalid_value) for _ in range(1000)] + assert colors == expected + + def test_luminosity_word(self): + if six.PY2: + expected = ['#7c000a', '#018748', '#dd8a0d', '#000068', '#7c9308'] + else: + expected = ['#2b7700', '#073c8c', '#d813aa', '#01961a', '#ce840e'] + colors = [self.random_color.generate(luminosity='dark') for _ in range(5)] + assert colors == expected + + if six.PY2: + expected = ['#11f7c9', '#3eafc9', '#6ad4f2', '#125582', '#e55098'] + else: + expected = ['#16b5ff', '#6266ef', '#fc4e3f', '#b2ff70', '#a30424'] + colors = [self.random_color.generate(luminosity='bright') for _ in range(5)] + assert colors == expected + + if six.PY2: + expected = ['#ffafb5', '#fcc1a1', '#b7fcab', '#6df280', '#f599f7'] + else: + expected = ['#f276a1', '#fcec94', '#aaffe5', '#ffbd7f', '#98f9dc'] + colors = [self.random_color.generate(luminosity='light') for _ in range(5)] + assert colors == expected + + if six.PY2: + expected = ['#11140f', '#7f7674', '#262116', '#376d20', '#535e53'] + else: + expected = ['#070603', '#99a2a3', '#10a85c', '#3f4f0c', '#004f1c'] + colors = [self.random_color.generate(luminosity='random') for _ in range(5)] + assert colors == expected + + def test_luminosity_invalid(self): + baseline_random_color = RandomColor(seed=self.seed) + expected = [baseline_random_color.generate() for _ in range(1000)] + + colors = [self.random_color.generate(luminosity='invalid_value') for _ in range(1000)] + assert colors == expected + class TestHyAM(unittest.TestCase): """ Tests colors in the hy_AM locale """ From cc86703bdc19f6797b2b341e92e4228b63ac4127 Mon Sep 17 00:00:00 2001 From: Austin Pua Date: Mon, 18 Nov 2019 13:39:46 +0800 Subject: [PATCH 2/2] Update files as per reviewer feedback --- faker/providers/color/color.py | 14 +++++++++----- tests/providers/test_color.py | 10 ++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/faker/providers/color/color.py b/faker/providers/color/color.py index e4fa839325..c354de7956 100644 --- a/faker/providers/color/color.py +++ b/faker/providers/color/color.py @@ -93,9 +93,9 @@ def __init__(self, generator=None, seed=None): for color_name, color_attrs in self.colormap.items(): lower_bounds = color_attrs['lower_bounds'] s_min = lower_bounds[0][0] - s_max = lower_bounds[len(lower_bounds) - 1][0] + s_max = lower_bounds[-1][0] - b_min = lower_bounds[len(lower_bounds) - 1][1] + b_min = lower_bounds[-1][1] b_max = lower_bounds[0][1] self.colormap[color_name]['saturation_range'] = [s_min, s_max] @@ -118,7 +118,7 @@ def pick_hue(self, hue): hue_range = self.get_hue_range(hue) hue = self.random_within(hue_range) - # Instead of storing red as two seperate ranges, + # Instead of storing red as two separate ranges, # we group them, using negative numbers if hue < 0: hue += 360 @@ -204,12 +204,16 @@ def get_hue_range(self, color_input): elif isinstance(color_input, six.string_types) and color_input in self.colormap: return self.colormap[color_input]['hue_range'] + elif color_input is None: + return [0, 360] + try: v1, v2 = color_input v1 = int(v1) v2 = int(v2) except (ValueError, TypeError): - return [0, 360] + msg = 'Hue must be a valid string, numeric type, or a tuple/list of 2 numeric types.' + raise TypeError(msg) else: if v2 < v1: v1, v2 = v2, v1 @@ -231,7 +235,7 @@ def get_color_info(self, hue): if color['hue_range'][0] <= hue <= color['hue_range'][1]: return self.colormap[color_name] else: - raise ValueError('Value of hue is invalid.') + raise ValueError('Value of hue `%s` is invalid.' % hue) def random_within(self, r): return self.random.randint(int(r[0]), int(r[1])) diff --git a/tests/providers/test_color.py b/tests/providers/test_color.py index 8593143c6d..073a911d6c 100644 --- a/tests/providers/test_color.py +++ b/tests/providers/test_color.py @@ -201,11 +201,6 @@ def test_hue_tuple_inverted_values(self): assert colors == expected def test_hue_invalid(self): - baseline_random_color = RandomColor(seed=self.seed) - expected = [baseline_random_color.generate() for _ in range(1000)] - - # Using any invalid value should yield the same results - # as not specifying a hue value for a given seed invalid_values = [ 'invalid value', # Unsupported string [1, 2, 3], # List with incorrect number of elements of valid data types @@ -214,9 +209,8 @@ def test_hue_invalid(self): ] for invalid_value in invalid_values: - random_color = RandomColor(seed=self.seed) - colors = [random_color.generate(hue=invalid_value) for _ in range(1000)] - assert colors == expected + with self.assertRaises(TypeError): + self.random_color.generate(hue=invalid_value) def test_luminosity_word(self): if six.PY2: