From 7449ae1221e1dc797a33bf521c646ef23dd31fab Mon Sep 17 00:00:00 2001 From: Chris Bridge Date: Tue, 6 Oct 2026 17:26:30 -0400 Subject: [PATCH] Update color space transformations --- docs/release_notes.rst | 28 +++++ src/highdicom/color.py | 244 ++++++++++++++++++++++++++--------------- tests/test_color.py | 171 +++++++++++++++++++++++++++-- 3 files changed, 345 insertions(+), 98 deletions(-) diff --git a/docs/release_notes.rst b/docs/release_notes.rst index bc06a2d1b..ebdbc4dfd 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -240,3 +240,31 @@ can be replaced with this to give the same output object: ], ..., ) + +.. _cielab-d50: + +CIELab conversions now use the D50 white point +---------------------------------------------- + +DICOM encodes CIELab values (such as Recommended Display CIELab Value) in the +same way as the ICC Profile Connection Space (see :dcm:`PS3.3 C.10.7.1.1 +`), i.e. relative to the D50 +illuminant, whereas the native white point of sRGB is D65. + +Prior to highdicom 0.29.0, the conversions implemented by +:class:`highdicom.color.CIELabColor` (``from_rgb``, ``from_string`` and +``to_rgb``) omitted the chromatic adaptation between the two white points and +therefore produced CIELab values relative to D65. From highdicom 0.29.0, sRGB +values are linearized, converted to CIE XYZ (D65), chromatically adapted to +D50 using the linear Bradford transform, and only then converted to CIELab +(and vice versa). The matrices and constants used are those of the `CSS Color +Module Level 4 sample code +`_, matching the +behavior of DCMTK (changed after version 3.7.0) and PixelMed. + +No code changes are required, but note that the CIELab values stored in files +created with highdicom 0.29.0 and later differ slightly from those created +with earlier versions for the same RGB input. Neutral (gray) colors are +unaffected; for chromatic colors the a* and b* components change. For example, +pure red (255, 0, 0) was previously encoded as L*a*b* (53.23, 80.11, 67.22) +and is now encoded as (54.29, 80.81, 69.89). diff --git a/src/highdicom/color.py b/src/highdicom/color.py index 364b5d3c5..4aec0921a 100644 --- a/src/highdicom/color.py +++ b/src/highdicom/color.py @@ -19,12 +19,28 @@ logger = logging.getLogger(__name__) +# White points, defined by their 4-figure CIE x,y chromaticities, as in the +# sample code of the CSS Color Module Level 4 +# (https://www.w3.org/TR/css-color-4/#color-conversion-code). +# +# DICOM encodes CIELab values in the same way as the ICC Profile Connection +# Space (PCS, see PS3.3 C.10.7.1.1), i.e. relative to the D50 illuminant, +# whereas the native white point of sRGB is D65. The conversions below +# therefore chromatically adapt between the two using the linear Bradford +# transform. Note that for neutral (gray) colors the results are the same +# either way, for chromatic colors the a* and b* values differ slightly. +_D50_WHITEPOINT_X = 0.3457 / 0.3585 +_D50_WHITEPOINT_Y = 1.0 +_D50_WHITEPOINT_Z = (1.0 - 0.3457 - 0.3585) / 0.3585 + + def _rgb_to_xyz(r: float, g: float, b: float) -> tuple[float, float, float]: - """Convert an RGB color to CIE XYZ representation. + """Convert an sRGB color to CIE XYZ representation. - Outputs are scaled between 0.0 and the white point (95.05, 100.0, 108.89). - As a private function, no checks are performed that input values are valid, - and output values are not clipped. + Outputs are relative to the D50 white point (ICC PCS XYZ) and scaled + between 0.0 and the white point (0.9642, 1.0, 0.8251). As a private + function, no checks are performed that input values are valid, and output + values are not clipped. Parameters ---------- @@ -38,47 +54,79 @@ def _rgb_to_xyz(r: float, g: float, b: float) -> tuple[float, float, float]: Returns ------- x: float - X component as a float between 0.0 and 95.05. + X component as a float between 0.0 and 0.9642. y: float - Y component as a float between 0.0 and 100.0. + Y component as a float between 0.0 and 1.0. z: float - Z component as a float between 0.0 and 108.89. + Z component as a float between 0.0 and 0.8251. """ - # Adapted from ColorUtilities module of pixelmed: - # https://www.dclunie.com/pixelmed/software/javadoc/com/pixelmed/utils/ColorUtilities.html - def convert_component(c: float) -> float: + # Matches the implementation of DCMTK's IODCIELabUtil::rgb2Xyz() + def invert_gamma(c: float) -> float: + # sRGB transfer function (IEC 61966-2-1), gamma-encoded to linear c = c / 255.0 if c > 0.04045: return ((c + 0.055) / 1.055) ** 2.4 return c / 12.92 - r = convert_component(r) * 100 - g = convert_component(g) * 100 - b = convert_component(b) * 100 - - x = r * 0.4124 + g * 0.3576 + b * 0.1805 - y = r * 0.2126 + g * 0.7152 + b * 0.0722 - z = r * 0.0193 + g * 0.1192 + b * 0.9505 + r = invert_gamma(r) + g = invert_gamma(g) + b = invert_gamma(b) + + # Linear RGB to CIE XYZ (D65) + x65 = ( + (506752.0 / 1228815.0) * r + + (87881.0 / 245763.0) * g + + (12673.0 / 70218.0) * b + ) + y65 = ( + (87098.0 / 409605.0) * r + + (175762.0 / 245763.0) * g + + (12673.0 / 175545.0) * b + ) + z65 = ( + (7918.0 / 409605.0) * r + + (87881.0 / 737289.0) * g + + (1001167.0 / 1053270.0) * b + ) + + # Chromatic adaptation from D65 to D50 (linear Bradford) + x = ( + 1.0479297925449969 * x65 + + 0.022946870601609652 * y65 - + 0.05019226628920524 * z65 + ) + y = ( + 0.02962780877005599 * x65 + + 0.9904344267538799 * y65 - + 0.017073799063418826 * z65 + ) + z = ( + -0.009243040646204504 * x65 + + 0.015055191490298152 * y65 + + 0.7518742814281371 * z65 + ) return x, y, z def _xyz_to_rgb(x: float, y: float, z: float) -> tuple[float, float, float]: - """Convert a CIE XYZ color to RGB representation. + """Convert a CIE XYZ color to sRGB representation. - Inputs are scaled between 0.0 and the white point (95.05, 100.0, 108.89). - As a private function, no checks are performed that input values are valid, - and output values are not clipped. + Inputs are relative to the D50 white point (ICC PCS XYZ) and scaled + between 0.0 and the white point (0.9642, 1.0, 0.8251). As a private + function, no checks are performed that input values are valid, and output + values are not clipped. Consequently colors that lie outside the sRGB + gamut give values outside the 0.0 to 255.0 range. Parameters ---------- x: float - X component as a float between 0.0 and 95.05. + X component as a float between 0.0 and 0.9642. y: float - Y component as a float between 0.0 and 100.0. + Y component as a float between 0.0 and 1.0. z: float - Z component as a float between 0.0 and 108.89. + Z component as a float between 0.0 and 0.8251. Returns ------- @@ -90,42 +138,67 @@ def _xyz_to_rgb(x: float, y: float, z: float) -> tuple[float, float, float]: Blue component between 0.0 and 255.0 (inclusive). """ - # Adapted from ColorUtilities module of pixelmed: - # https://www.dclunie.com/pixelmed/software/javadoc/com/pixelmed/utils/ColorUtilities.html - x = x / 100 - y = y / 100 - z = z / 100 - - r = x * 3.2406 + y * -1.5372 + z * -0.4986 - g = x * -0.9689 + y * 1.8758 + z * 0.0415 - b = x * 0.0557 + y * -0.2040 + z * 1.0570 - - def convert_component(c: float) -> float: + # Matches the implementation of DCMTK's IODCIELabUtil::xyz2Rgb(), except + # that out-of-gamut values are not clipped here (this is left to the + # caller, which may instead want to report them as out of gamut) + # Chromatic adaptation from D50 to D65 (linear Bradford) + x65 = ( + 0.955473421488075 * x - + 0.02309845494876471 * y + + 0.06325924320057072 * z + ) + y65 = ( + -0.0283697093338637 * x + + 1.0099953980813041 * y + + 0.021041441191917323 * z + ) + z65 = ( + 0.012314014864481998 * x - + 0.020507649298898964 * y + + 1.330365926242124 * z + ) + + # CIE XYZ (D65) to linear RGB + r = ( + (12831.0 / 3959.0) * x65 - + (329.0 / 214.0) * y65 - + (1974.0 / 3959.0) * z65 + ) + g = ( + (-851781.0 / 878810.0) * x65 + + (1648619.0 / 878810.0) * y65 + + (36519.0 / 878810.0) * z65 + ) + b = ( + (705.0 / 12673.0) * x65 - + (2585.0 / 12673.0) * y65 + + (705.0 / 667.0) * z65 + ) + + def apply_gamma(c: float) -> float: + # sRGB transfer function (IEC 61966-2-1), linear to gamma-encoded if c > 0.0031308: - return 1.055 * (c ** (1 / 2.4)) - 0.055 + return 1.055 * (c ** (1.0 / 2.4)) - 0.055 return 12.92 * c - r = convert_component(r) * 255 - g = convert_component(g) * 255 - b = convert_component(b) * 255 - - return r, g, b + return apply_gamma(r) * 255, apply_gamma(g) * 255, apply_gamma(b) * 255 def _xyz_to_lab(x: float, y: float, z: float) -> tuple[float, float, float]: """Convert a CIE XYZ color to CIE Lab representation. - As a private function, no checks are performed that input values are valid, - and output values are not clipped. + Both input and output are relative to the D50 white point (ICC PCS). As a + private function, no checks are performed that input values are valid, and + output values are not clipped. Parameters ---------- x: float - X component. + X component as a float between 0.0 and 0.9642. y: float - Y component. + Y component as a float between 0.0 and 1.0. z: float - Z component. + Z component as a float between 0.0 and 0.8251. Returns ------- @@ -137,16 +210,16 @@ def _xyz_to_lab(x: float, y: float, z: float) -> tuple[float, float, float]: Blue-yellow value from -128.0 (blue) to 127.0 (yellow). """ - # Adapted from ColorUtilities module of pixelmed: - # https://www.dclunie.com/pixelmed/software/javadoc/com/pixelmed/utils/ColorUtilities.html - x = x / 95.047 - y = y / 100.0 - z = z / 108.883 + # Matches the implementation of DCMTK's IODCIELabUtil::xyz2Lab() + x = x / _D50_WHITEPOINT_X + y = y / _D50_WHITEPOINT_Y + z = z / _D50_WHITEPOINT_Z def convert_component(c: float) -> float: - if c >= 8.85645167903563082e-3: - return c ** (1.0 / 3) - return (841.0 / 108.0) * c + (4.0 / 29.0) + # epsilon = (6/29)^3 = 216/24389, kappa = (29/3)^3 = 24389/27 + if c > 216.0 / 24389.0: + return c ** (1.0 / 3.0) + return ((24389.0 / 27.0) * c + 16.0) / 116.0 x = convert_component(x) y = convert_component(y) @@ -166,9 +239,10 @@ def _lab_to_xyz( ) -> tuple[float, float, float]: """Convert a CIE Lab color to CIE XYZ representation. - Outputs are scaled between 0.0 and the white point (95.05, 100.0, 108.89). - As a private function, no checks are performed that input values are valid, - and output values are not clipped. + Both input and output are relative to the D50 white point (ICC PCS). + Outputs are scaled between 0.0 and the white point (0.9642, 1.0, 0.8251). + As a private function, no checks are performed that input values are + valid, and output values are not clipped. Parameters ---------- @@ -182,42 +256,37 @@ def _lab_to_xyz( Returns ------- x: float - X component. + X component as a float between 0.0 and 0.9642. y: float - Y component. + Y component as a float between 0.0 and 1.0. z: float - Z component. + Z component as a float between 0.0 and 0.8251. """ - # Adapted from ColorUtilities module of pixelmed: - # https://www.dclunie.com/pixelmed/software/javadoc/com/pixelmed/utils/ColorUtilities.html + # Matches the implementation of DCMTK's IODCIELabUtil::lab2Xyz() y = (l_star + 16) / 116 x = a_star / 500 + y z = y - b_star / 200 def convert_component(c: float) -> float: - c3 = c ** 3 - - if c3 > 0.008856: - return c3 - return (c - 16.0 / 116) / 7.787 - - x = convert_component(x) - y = convert_component(y) - z = convert_component(z) + # c > 6/29 is equivalent to c ** 3 > epsilon + if c > 6.0 / 29.0: + return c ** 3 + return (116.0 * c - 16.0) / (24389.0 / 27.0) - x = 95.047 * x - y = 100.0 * y - z = 108.883 * z + x = _D50_WHITEPOINT_X * convert_component(x) + y = _D50_WHITEPOINT_Y * convert_component(y) + z = _D50_WHITEPOINT_Z * convert_component(z) return x, y, z def _rgb_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]: - """Convert an RGB color to CIE Lab representation. + """Convert an sRGB color to CIE Lab representation. - As a private function, no checks are performed that input values are valid, - and output values are not clipped. + The output is relative to the D50 white point (ICC PCS), as used by DICOM. + As a private function, no checks are performed that input values are + valid, and output values are not clipped. Parameters ---------- @@ -246,11 +315,12 @@ def _lab_to_rgb( a_star: float, b_star: float, ) -> tuple[float, float, float]: - """Convert a CIE Lab color to RGB representation. + """Convert a CIE Lab color to sRGB representation. - As a private function, no checks are performed that input values are valid, - and output values are not clipped. Lab colors that cannot be represented in - RGB will have values outside to 0.0 to 255.0 range. + The input is relative to the D50 white point (ICC PCS), as used by DICOM. + As a private function, no checks are performed that input values are + valid, and output values are not clipped. Lab colors that cannot be + represented in sRGB will have values outside to 0.0 to 255.0 range. Parameters ---------- @@ -291,7 +361,7 @@ class CIELabColor: >>> >>> color = hd.color.CIELabColor(50.0, 34.0, 12.4) >>> print(color.to_rgb()) - (177, 95, 99) + (175, 94, 100) Construct a CIE-Lab color from an RGB color and examine the Lab components: @@ -299,7 +369,7 @@ class CIELabColor: >>> >>> color = hd.color.CIELabColor.from_rgb(0, 255, 0) >>> print(color.l_star, color.a_star, color.b_star) - 87.73632410162509 -86.1828793774319 83.1828793774319 + 87.81872281986725 -79.27237354085602 80.99610894941634 Construct a CIE-Lab color from the name of a well-known color: @@ -307,7 +377,7 @@ class CIELabColor: >>> >>> color = hd.color.CIELabColor.from_string('turquoise') >>> print(color.l_star, color.a_star, color.b_star) - 81.2664988174258 -44.07782101167315 -4.035019455252922 + 80.96131837949187 -45.13618677042801 -4.680933852140072 Within DICOM files, the three components are represented using scaled and shifted unsigned 16 bit integer values. You can move between these @@ -318,10 +388,10 @@ class CIELabColor: >>> color = hd.color.CIELabColor.from_string('orange') >>> # Print the values that would actually be stored in a DICOM file >>> print(color.value) - (49107, 39048, 53188) + (49538, 39968, 53230) >>> # Create a color directly from these values - >>> color2 = hd.color.CIELabColor.from_dicom_value((49107, 39048, 53188)) - >>>> print(color2.to_rgb()) + >>> color2 = hd.color.CIELabColor.from_dicom_value((49538, 39968, 53230)) + >>> print(color2.to_rgb()) (255, 165, 0) """ diff --git a/tests/test_color.py b/tests/test_color.py index acff5644e..5a6e98d7a 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -4,7 +4,17 @@ import pytest from PIL.ImageCms import ImageCmsProfile, createProfile -from highdicom.color import ColorManager, CIELabColor +from highdicom.color import ( + _D50_WHITEPOINT_X, + _D50_WHITEPOINT_Y, + _D50_WHITEPOINT_Z, + _lab_to_rgb, + _rgb_to_lab, + _rgb_to_xyz, + _xyz_to_rgb, + CIELabColor, + ColorManager, +) @pytest.mark.parametrize( @@ -24,18 +34,21 @@ def test_cielab(l_in, a_in, b_in, out): @pytest.mark.parametrize( - # Examples generated from colormine.org + # Expected values are CIELab relative to the D50 white point (the + # illuminant of the ICC Profile Connection Space, which is what DICOM + # uses), as produced by the sample code of the CSS Color Module Level 4 + # and by DCMTK's IODCIELabUtil 'r,g,b,l_out,a_out,b_out', [ [0, 0, 0, 0.0, 0.0, 0.0], - [255, 0, 0, 53.23, 80.11, 67.22], - [0, 255, 0, 87.74, -86.18, 83.18], - [0, 0, 255, 32.30, 79.20, -107.86], - [0, 255, 255, 91.11, -48.08, -14.14], - [255, 255, 0, 97.14, -21.56, 94.48], - [255, 0, 255, 60.32, 98.25, -60.84], - [255, 255, 255, 100.0, 0.0, -0.01], - [45, 123, 198, 50.45, 2.59, -45.75], + [255, 0, 0, 54.291, 80.805, 69.891], + [0, 255, 0, 87.819, -79.271, 80.995], + [0, 0, 255, 29.568, 68.287, -112.030], + [0, 255, 255, 90.666, -50.656, -14.962], + [255, 255, 0, 97.607, -15.750, 93.394], + [255, 0, 255, 60.169, 93.540, -60.501], + [255, 255, 255, 100.0, 0.0, 0.0], + [45, 123, 198, 49.75, -3.84, -46.70], ] ) def test_from_rgb(r, g, b, l_out, a_out, b_out): @@ -63,7 +76,7 @@ def test_to_rgb_invalid(): # With clip=True, will clip to closest representable value r, g, b = color.to_rgb(clip=True) assert r == 255 - assert g == 125 + assert g == 107 assert b == 255 @@ -113,6 +126,142 @@ def test_cielab_invalid(l_in, a_in, b_in): CIELabColor(l_in, a_in, b_in) +# Reference colors used by DCMTK's test suite for IODCIELabUtil: sRGB (8 bit) +# and the corresponding D50 CIELab (ICC PCS) values, as computed by the sample +# code of the CSS Color Module Level 4. The first part are CSS named colors, +# the second part are colors from 3D Slicer's "General Anatomy" color table. +REFERENCE_COLORS = [ + ('black', 0, 0, 0, 0.000, 0.000, 0.000), + ('white', 255, 255, 255, 100.000, 0.000, 0.000), + ('gray', 128, 128, 128, 53.585, 0.000, 0.000), + ('silver', 192, 192, 192, 77.704, 0.000, 0.000), + ('red', 255, 0, 0, 54.291, 80.805, 69.891), + ('lime', 0, 255, 0, 87.819, -79.271, 80.995), + ('blue', 0, 0, 255, 29.568, 68.287, -112.030), + ('yellow', 255, 255, 0, 97.607, -15.750, 93.394), + ('cyan', 0, 255, 255, 90.666, -50.656, -14.962), + ('magenta', 255, 0, 255, 60.169, 93.540, -60.501), + ('maroon', 128, 0, 0, 26.165, 48.473, 39.439), + ('navy', 0, 0, 128, 11.335, 40.964, -67.203), + ('olive', 128, 128, 0, 52.150, -9.448, 56.024), + ('teal', 0, 128, 128, 47.986, -30.387, -8.975), + ('purple', 128, 0, 128, 29.692, 56.112, -36.293), + ('orange', 255, 165, 0, 75.590, 27.516, 79.121), + ('cornflowerblue', 100, 149, 237, 61.233, 3.047, -50.188), + ('darkslategray', 47, 79, 79, 31.141, -12.261, -3.937), + ('hotpink', 255, 105, 180, 65.860, 63.258, -9.644), + ('indigo', 75, 0, 130, 19.715, 47.029, -54.278), + ('lavender', 230, 230, 250, 91.742, 2.775, -9.724), + ('midnightblue', 25, 25, 112, 14.929, 25.955, -50.904), + ('peru', 205, 133, 63, 62.253, 23.948, 48.413), + ('springgreen', 0, 255, 127, 88.436, -72.499, 45.977), + ('CSF space', 85, 188, 255, 72.318, -15.277, -42.679), + ('aorta', 224, 97, 76, 57.648, 49.583, 37.620), + ('bile', 0, 145, 30, 52.261, -51.068, 46.791), + ('bone', 241, 214, 145, 86.748, 2.835, 37.696), + ('brain', 250, 250, 225, 97.780, -3.106, 12.048), + ('capillary', 183, 156, 220, 68.539, 19.645, -28.946), + ('cartilage', 111, 184, 210, 70.737, -18.519, -20.706), + ('gray matter', 200, 200, 235, 81.452, 5.183, -17.387), + ('liver', 221, 130, 101, 64.063, 33.878, 31.516), + ('muscle', 192, 104, 88, 54.284, 34.766, 25.456), + ('pancreas', 249, 180, 111, 78.903, 20.532, 45.266), + ('skin', 177, 122, 101, 56.608, 20.206, 20.612), + ('spleen', 157, 108, 162, 52.334, 27.009, -21.229), + ('thyroid gland', 62, 162, 114, 59.883, -39.102, 16.066), + ('vein', 0, 151, 206, 57.968, -19.204, -38.353), + ('white matter', 250, 250, 210, 97.515, -4.805, 19.293), +] + + +@pytest.mark.parametrize('name,r,g,b,l_out,a_out,b_out', REFERENCE_COLORS) +def test_reference_colors(name, r, g, b, l_out, a_out, b_out): + # RGB -> CIELab must match the D50 reference values + l_star, a_star, b_star = _rgb_to_lab(r, g, b) + assert abs(l_star - l_out) < 0.03 + assert abs(a_star - a_out) < 0.03 + assert abs(b_star - b_out) < 0.03 + + # The reference values are rounded to 3 decimals, which may shift a + # component that lies exactly on a gamut boundary by one + r_out, g_out, b_out_ = (round(c) for c in _lab_to_rgb(l_out, a_out, b_out)) + assert abs(r_out - r) <= 1 + assert abs(g_out - g) <= 1 + assert abs(b_out_ - b) <= 1 + + +def test_white_point(): + # sRGB white maps exactly onto the D50 white point of the ICC PCS + assert _rgb_to_xyz(255, 255, 255) == pytest.approx( + (_D50_WHITEPOINT_X, _D50_WHITEPOINT_Y, _D50_WHITEPOINT_Z), + abs=1e-9, + ) + assert _rgb_to_lab(255, 255, 255) == pytest.approx((100.0, 0.0, 0.0)) + + # The D50 white point of ICC v4.3 Table 14 maps back onto sRGB white + assert _xyz_to_rgb(0.96422, 1.0, 0.82521) == pytest.approx( + (255.0, 255.0, 255.0), abs=0.1 + ) + + # White and black have the expected DICOM encodings (ICC v4.3 Table 14) + assert CIELabColor.from_rgb(255, 255, 255).value == (0xFFFF, 0x8080, 0x8080) + assert CIELabColor.from_rgb(0, 0, 0).value == (0x0000, 0x8080, 0x8080) + + +@pytest.mark.parametrize( + 'value,rgb_out', + [ + # Values checked against DCMTK (and PixelMed), all of which lie + # marginally outside the sRGB gamut and therefore require clipping + [(35732, 48892, 14692), (181, 82, 255)], + [(0, 0x8000, 0x8000), (0, 0, 1)], + # Saturated blue, slightly outside the sRGB gamut in linear RGB + [(19378, 50557, 3680), (0, 0, 255)], + ] +) +def test_dicom_value_to_rgb_out_of_gamut(value, rgb_out): + color = CIELabColor.from_dicom_value(value) + + with pytest.raises(ValueError): + color.to_rgb() + + assert color.to_rgb(clip=True) == rgb_out + + +@pytest.mark.parametrize( + 'r,g,b', + # Values from DCMTK's (and PixelMed's) test suite + [ + (0, 0, 0), + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + (255, 255, 0), + (0, 255, 255), + (255, 0, 255), + (255, 255, 255), + (225, 190, 150), + (200, 200, 200), + (128, 174, 128), + (221, 130, 101), + (0x51, 0x5d, 0xe5), + (0x4c, 0x6e, 0xda), + ] +) +def test_rgb_round_trip(r, g, b): + # An 8 bit RGB triple must survive the round trip through the 16 bit + # DICOM CIELab encoding unchanged + assert CIELabColor.from_rgb(r, g, b).to_rgb() == (r, g, b) + + +def test_rgb_round_trip_cube(): + # As above, but for a sample of the whole RGB cube + for r in range(0, 256, 17): + for g in range(0, 256, 17): + for b in range(0, 256, 17): + assert CIELabColor.from_rgb(r, g, b).to_rgb() == (r, g, b) + + class TestColorManager(unittest.TestCase): def setUp(self) -> None: