diff --git a/colormath/color_conversions.py b/colormath/color_conversions.py index 67af1eb..766a320 100644 --- a/colormath/color_conversions.py +++ b/colormath/color_conversions.py @@ -62,10 +62,6 @@ def apply_RGB_matrix(var1, var2, var3, rgb_type, convtype="xyz_to_rgb"): # Perform the adaptation via matrix multiplication. result_matrix = numpy.dot(rgb_matrix, var_matrix) rgb_r, rgb_g, rgb_b = result_matrix - # Clamp these values to a valid range. - rgb_r = max(rgb_r, 0.0) - rgb_g = max(rgb_g, 0.0) - rgb_b = max(rgb_b, 0.0) return rgb_r, rgb_g, rgb_b @@ -555,7 +551,8 @@ def XYZ_to_RGB(cobj, target_rgb, *args, **kwargs): else: # If it's not sRGB... for channel in ["r", "g", "b"]: - v = linear_channels[channel] + # Clamp value to a valid range. + v = max(linear_channels[channel], 0.0) nonlinear_channels[channel] = math.pow(v, 1 / target_rgb.rgb_gamma) return target_rgb( diff --git a/colormath/color_objects.py b/colormath/color_objects.py index 0dd315b..f1998a7 100644 --- a/colormath/color_objects.py +++ b/colormath/color_objects.py @@ -5,6 +5,7 @@ import logging import math +import warnings import numpy @@ -591,6 +592,9 @@ def __init__(self, xyy_x, xyy_y, xyy_Y, observer="2", illuminant="d50"): self.set_illuminant(illuminant) +_DO_NOT_USE = object() + + class BaseRGBColor(ColorBase): """ Base class for all RGB color spaces. @@ -600,59 +604,84 @@ class BaseRGBColor(ColorBase): VALUES = ["rgb_r", "rgb_g", "rgb_b"] - def __init__(self, rgb_r, rgb_g, rgb_b, is_upscaled=False): - """ - :param float rgb_r: R coordinate. 0.0-1.0, or 0-255 if is_upscaled=True. - :param float rgb_g: G coordinate. 0.0-1.0, or 0-255 if is_upscaled=True. - :param float rgb_b: B coordinate. 0.0-1.0, or 0-255 if is_upscaled=True. - :keyword bool is_upscaled: If False, RGB coordinate values are - beteween 0.0 and 1.0. If True, RGB values are between 0 and 255. - """ - super(BaseRGBColor, self).__init__() - if is_upscaled: - self.rgb_r = rgb_r / 255.0 - self.rgb_g = rgb_g / 255.0 - self.rgb_b = rgb_b / 255.0 - else: - self.rgb_r = float(rgb_r) - self.rgb_g = float(rgb_g) - self.rgb_b = float(rgb_b) - self.is_upscaled = is_upscaled + def __new__(cls, rgb_r, rgb_g, rgb_b, is_upscaled=_DO_NOT_USE): + if is_upscaled is not _DO_NOT_USE: + warnings.warn( + ( + "is_upscaled flag is deprecated, use %s.new_from_upscaled" + "(rgb_r, rgb_g, rgb_b) instead" + ) + % cls.__name__, + DeprecationWarning, + stacklevel=2, + ) + if is_upscaled: + # __init__ will be called twice here + return cls.new_from_upscaled(rgb_r, rgb_g, rgb_b) + return super(BaseRGBColor, cls).__new__(cls) - def _clamp_rgb_coordinate(self, coord): + def __init__(self, rgb_r, rgb_g, rgb_b, is_upscaled=_DO_NOT_USE): """ - Clamps an RGB coordinate, taking into account whether or not the - color is upscaled or not. - - :param float coord: The coordinate value. - :rtype: float - :returns: The clamped value. + :param float rgb_r: R coordinate. + :param float rgb_g: G coordinate. + :param float rgb_b: B coordinate. + :keyword is_upscaled: deprecated. """ - if not self.is_upscaled: - return min(max(coord, 0.0), 1.0) - else: - return min(max(coord, 0.0), 255.0) + if is_upscaled is not _DO_NOT_USE: + # avoid second __init__ call + return + super(BaseRGBColor, self).__init__() + self.rgb_r = float(rgb_r) + self.rgb_g = float(rgb_g) + self.rgb_b = float(rgb_b) @property def clamped_rgb_r(self): """ The clamped (0.0-1.0) R value. """ - return self._clamp_rgb_coordinate(self.rgb_r) + warnings.warn( + "color.clamped_rgb_r is deprecated, use color.clamped().rgb_r instead", + DeprecationWarning, + stacklevel=2, + ) + return self.clamped().rgb_r @property def clamped_rgb_g(self): """ The clamped (0.0-1.0) G value. """ - return self._clamp_rgb_coordinate(self.rgb_g) + warnings.warn( + "color.clamped_rgb_g is deprecated, use color.clamped().rgb_g instead", + DeprecationWarning, + stacklevel=2, + ) + return self.clamped().rgb_g @property def clamped_rgb_b(self): """ The clamped (0.0-1.0) B value. """ - return self._clamp_rgb_coordinate(self.rgb_b) + warnings.warn( + "color.clamped_rgb_b is deprecated, use color.clamped().rgb_b instead", + DeprecationWarning, + stacklevel=2, + ) + return self.clamped().rgb_b + + def clamped(self): + """ + Return copy of this color with coordinates clipped to fit in 0.0-1.0 range. + + :rtype: sRGBColor + """ + return type(self)( + min(max(0.0, self.rgb_r), 1.0), + min(max(0.0, self.rgb_g), 1.0), + min(max(0.0, self.rgb_b), 1.0), + ) def get_upscaled_value_tuple(self): """ @@ -671,7 +700,7 @@ def get_rgb_hex(self): :rtype: str """ - rgb_r, rgb_g, rgb_b = self.get_upscaled_value_tuple() + rgb_r, rgb_g, rgb_b = self.clamped().get_upscaled_value_tuple() return "#%02x%02x%02x" % (rgb_r, rgb_g, rgb_b) @classmethod @@ -687,9 +716,16 @@ def new_from_rgb_hex(cls, hex_str): colorstring = colorstring[1:] if len(colorstring) != 6: raise ValueError("input #%s is not in #RRGGBB format" % colorstring) - r, g, b = colorstring[:2], colorstring[2:4], colorstring[4:] - r, g, b = [int(n, 16) / 255.0 for n in (r, g, b)] - return cls(r, g, b) + return cls.new_from_upscaled( + int(colorstring[:2], 16), + int(colorstring[2:4], 16), + int(colorstring[4:], 16), + ) + + @classmethod + def new_from_upscaled(cls, r, g, b): + """Create new RGB color from coordinates in range 0-255.""" + return cls(r / 255.0, g / 255.0, b / 255.0) # noinspection PyPep8Naming @@ -697,15 +733,9 @@ class sRGBColor(BaseRGBColor): """ Represents an sRGB color. - .. note:: If you pass in upscaled values, we automatically scale them - down to 0.0-1.0. If you need the old upscaled values, you can - retrieve them with :py:meth:`get_upscaled_value_tuple`. - :ivar float rgb_r: R coordinate :ivar float rgb_g: G coordinate :ivar float rgb_b: B coordinate - :ivar bool is_upscaled: If True, RGB values are between 0-255. If False, - 0.0-1.0. """ #: RGB space's gamma constant. @@ -734,15 +764,9 @@ class BT2020Color(BaseRGBColor): """ Represents a ITU-R BT.2020 color. - .. note:: If you pass in upscaled values, we automatically scale them - down to 0.0-1.0. If you need the old upscaled values, you can - retrieve them with :py:meth:`get_upscaled_value_tuple`. - :ivar float rgb_r: R coordinate :ivar float rgb_g: G coordinate :ivar float rgb_b: B coordinate - :ivar bool is_upscaled: If True, RGB values are between 0-255. If False, - 0.0-1.0. """ #: RGB space's gamma constant. @@ -771,15 +795,9 @@ class AdobeRGBColor(BaseRGBColor): """ Represents an Adobe RGB color. - .. note:: If you pass in upscaled values, we automatically scale them - down to 0.0-1.0. If you need the old upscaled values, you can - retrieve them with :py:meth:`get_upscaled_value_tuple`. - :ivar float rgb_r: R coordinate :ivar float rgb_g: G coordinate :ivar float rgb_b: B coordinate - :ivar bool is_upscaled: If True, RGB values are between 0-255. If False, - 0.0-1.0. """ #: RGB space's gamma constant. @@ -808,15 +826,9 @@ class AppleRGBColor(BaseRGBColor): """ Represents an AppleRGB color. - .. note:: If you pass in upscaled values, we automatically scale them - down to 0.0-1.0. If you need the old upscaled values, you can - retrieve them with :py:meth:`get_upscaled_value_tuple`. - :ivar float rgb_r: R coordinate :ivar float rgb_g: G coordinate :ivar float rgb_b: B coordinate - :ivar bool is_upscaled: If True, RGB values are between 0-255. If False, - 0.0-1.0. """ #: RGB space's gamma constant. diff --git a/doc_src/conversions.rst b/doc_src/conversions.rst index d28be9b..12c5a88 100644 --- a/doc_src/conversions.rst +++ b/doc_src/conversions.rst @@ -85,13 +85,9 @@ RGB conversions and out-of-gamut coordinates RGB spaces tend to have a smaller gamut than some of the CIE color spaces. When converting to RGB, this can cause some of the coordinates to end up -being out of the acceptable range (0.0-1.0 or 0-255, depending on whether -your RGB color is upscaled). +being out of the acceptable range (0.0-1.0). Rather than clamp these for you, we leave them as-is. This allows for more accurate conversions back to the CIE color spaces. If you require the clamped -(0.0-1.0 or 0-255) values, use the following properties on any RGB color: - -* ``clamped_rgb_r`` -* ``clamped_rgb_g`` -* ``clamped_rgb_b`` +values, call ``clamped()`` from any RGB color to get copy of this color with +coordinates clipped to fit in 0.0-1.0 range. diff --git a/tests/test_color_objects.py b/tests/test_color_objects.py index 40b1bf2..98c2bfd 100644 --- a/tests/test_color_objects.py +++ b/tests/test_color_objects.py @@ -305,18 +305,35 @@ def test_channel_clamping(self): self.assertEqual(low_b.clamped_rgb_g, low_b.rgb_g) self.assertEqual(low_b.clamped_rgb_b, 0.0) + def test_clamped(self): + for (r, g, b), expected in [ + ((-.482, -.784, -.196), (0., 0., 0.)), + ((1.482, -.784, -.196), (1., 0., 0.)), + ((-.482, 1.784, -.196), (0., 1., 0.)), + ((1.482, 1.784, -.196), (1., 1., 0.)), + ((-.482, -.784, 1.196), (0., 0., 1.)), + ((1.482, -.784, 1.196), (1., 0., 1.)), + ((-.482, 1.784, 1.196), (0., 1., 1.)), + ((1.482, 1.784, 1.196), (1., 1., 1.)), + ((0.482, 0.784, 0.196), (0.482, 0.784, 0.196)), + ]: + self.assertEqual( + sRGBColor(r, g, b).clamped().get_value_tuple(), + expected, + ) + def test_to_xyz_and_back(self): xyz = convert_color(self.color, XYZColor) rgb = convert_color(xyz, sRGBColor) self.assertColorMatch(rgb, self.color) def test_conversion_to_hsl_max_r(self): - color = sRGBColor(255, 123, 50, is_upscaled=True) + color = sRGBColor.new_from_upscaled(255, 123, 50) hsl = convert_color(color, HSLColor) self.assertColorMatch(hsl, HSLColor(21.366, 1.000, 0.598)) def test_conversion_to_hsl_max_g(self): - color = sRGBColor(123, 255, 50, is_upscaled=True) + color = sRGBColor.new_from_upscaled(123, 255, 50) hsl = convert_color(color, HSLColor) self.assertColorMatch(hsl, HSLColor(98.634, 1.000, 0.598)) @@ -404,6 +421,20 @@ def test_set_from_rgb_hex(self): rgb = sRGBColor.new_from_rgb_hex("#7bc832") self.assertColorMatch(rgb, sRGBColor(0.482, 0.784, 0.196)) + def test_set_from_rgb_hex_no_sharp(self): + rgb = sRGBColor.new_from_rgb_hex('7bc832') + self.assertColorMatch(rgb, sRGBColor(0.482, 0.784, 0.196)) + + def test_set_from_rgb_hex_invalid_length(self): + with self.assertRaises(ValueError): + sRGBColor.new_from_rgb_hex('#ccc') + + def test_get_upscaled_value_tuple(self): + self.assertEqual( + self.color.get_upscaled_value_tuple(), + (123, 200, 50), + ) + class HSLConversionTestCase(BaseColorConversionTest): def setUp(self):