Skip to content
This repository was archived by the owner on Dec 12, 2023. It is now read-only.

[RFC] RGB clamping/upscaling API #97

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions colormath/color_conversions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down
132 changes: 72 additions & 60 deletions colormath/color_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import logging
import math
import warnings

import numpy

Expand Down Expand Up @@ -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.
Expand All @@ -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):
"""
Expand All @@ -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
Expand All @@ -687,25 +716,26 @@ 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
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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 3 additions & 7 deletions doc_src/conversions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 33 additions & 2 deletions tests/test_color_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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):
Expand Down