diff --git a/coloraide/everything.py b/coloraide/everything.py index 5ddd2bcff..7e8def303 100644 --- a/coloraide/everything.py +++ b/coloraide/everything.py @@ -38,6 +38,9 @@ from .spaces.ucs import UCS from .spaces.rec709 import Rec709 from .spaces.rec709_oetf import Rec709OETF +from .spaces.ycbcr709 import YPbPr709, YCbCr709Bit8, YCbCr709Bit10 +from .spaces.ycbcr2020 import YPbPr2020, YCbCr2020Bit10, YCbCr2020Bit12 +from .spaces.sycc import sYCC, sYCC8 from .spaces.ryb import RYB, RYBBiased from .spaces.cubehelix import Cubehelix from .spaces.rec2020_oetf import Rec2020OETF @@ -111,6 +114,14 @@ class ColorAll(Base): Msh(), sCAMJMh(), sUCS(), + sYCC(), + YPbPr709(), + YPbPr2020(), + sYCC8(), + YCbCr709Bit8(), + YCbCr709Bit10(), + YCbCr2020Bit10(), + YCbCr2020Bit12(), # Delta E DE99o(), diff --git a/coloraide/spaces/sycc.py b/coloraide/spaces/sycc.py new file mode 100644 index 000000000..b7ec9b05a --- /dev/null +++ b/coloraide/spaces/sycc.py @@ -0,0 +1,42 @@ +""" +The sYCC color space. + +The sYCC sped +- https://www.color.org/sycc.pdf + +Rec. 601 +- https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.601-7-201103-I!!PDF-E.pdf +""" +from __future__ import annotations +from .ycbcr709 import YPbPr, YCbCr, Environment +from ..channels import Channel +from ..cat import WHITES + +BT601 = (0.2990, 0.1140) + + +class sYCC(YPbPr): + """Y'CbCr color class using sRGB and BT.601 transform.""" + + BASE = 'srgb' + NAME = "sycc" + SERIALIZE = ("--sycc",) + WHITE = WHITES['2deg']['D65'] + ENV = Environment(k=BT601) + GAMUT_CHECK = 'srgb' + + +class sYCC8(YCbCr): + """YCbCr color class using sRGB and BT.601 transform (8 bit).""" + + BASE = 'srgb' + NAME = "sycc-8bit" + SERIALIZE = ("--sycc-8bit",) + WHITE = WHITES['2deg']['D65'] + ENV = Environment(k=BT601, bit_depth=8, standard=False) + CHANNELS = ( + Channel("y", ENV.y_range[0], ENV.y_range[1], nans=ENV.y_range[0], bound=True, limit=ENV.digital_round), + Channel("cb", ENV.c_range[0], ENV.c_range[1], nans=ENV.c_range[0], bound=True, limit=ENV.digital_round), + Channel("cr", ENV.c_range[0], ENV.c_range[1], nans=ENV.c_range[0], bound=True, limit=ENV.digital_round) + ) + GAMUT_CHECK = 'srgb' diff --git a/coloraide/spaces/ycbcr2020.py b/coloraide/spaces/ycbcr2020.py new file mode 100644 index 000000000..a316872e4 --- /dev/null +++ b/coloraide/spaces/ycbcr2020.py @@ -0,0 +1,55 @@ +""" +YCbCr color space. + +Rec. 2020 +- https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.2020-2-201510-I!!PDF-E.pdf +""" +from __future__ import annotations +from .ycbcr709 import YPbPr, YCbCr, Environment +from ..channels import Channel +from ..cat import WHITES + +BT2020 = (0.2627, 0.0593) + + +class YPbPr2020(YPbPr): + """YPbPr color class using Rec. 709.""" + + BASE = 'rec2020' + NAME = "ypbpr2020" + SERIALIZE = ("--ypbpr2020",) + WHITE = WHITES['2deg']['D65'] + ENV = Environment(k=BT2020) + GAMUT_CHECK = 'rec2020' + + +class YCbCr2020Bit10(YCbCr): + """Y'CbCr color class using Rec. 2020 (10 bit).""" + + BASE = 'rec2020' + NAME = "ycbcr2020-10bit" + SERIALIZE = ("--ycbcr2020-10bit",) + WHITE = WHITES['2deg']['D65'] + ENV = Environment(k=BT2020, bit_depth=10, standard=True) + CHANNELS = ( + Channel("y", ENV.y_range[0], ENV.y_range[1], nans=ENV.y_range[0], bound=True, limit=ENV.digital_round), + Channel("cb", ENV.c_range[0], ENV.c_range[1], nans=ENV.c_range[0], bound=True, limit=ENV.digital_round), + Channel("cr", ENV.c_range[0], ENV.c_range[1], nans=ENV.c_range[0], bound=True, limit=ENV.digital_round) + ) + GAMUT_CHECK = 'rec2020' + + +class YCbCr2020Bit12(YCbCr): + """Y'CbCr color class using Rec. 2020 (12 bit).""" + + BASE = 'rec2020' + NAME = "ycbcr2020-12bit" + SERIALIZE = ("--ycbcr2020-12bit",) + WHITE = WHITES['2deg']['D65'] + ENV = Environment(k=BT2020, bit_depth=12, standard=True) + CHANNELS = ( + Channel("y", ENV.y_range[0], ENV.y_range[1], nans=ENV.y_range[0], bound=True, limit=ENV.digital_round), + Channel("cb", ENV.c_range[0], ENV.c_range[1], nans=ENV.c_range[0], bound=True, limit=ENV.digital_round), + Channel("cr", ENV.c_range[0], ENV.c_range[1], nans=ENV.c_range[0], bound=True, limit=ENV.digital_round) + ) + GAMUT_CHECK = 'rec2020' diff --git a/coloraide/spaces/ycbcr709.py b/coloraide/spaces/ycbcr709.py new file mode 100644 index 000000000..5d76b8114 --- /dev/null +++ b/coloraide/spaces/ycbcr709.py @@ -0,0 +1,209 @@ +""" +YCbCr color space. + +Rec. 709 +- https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf + +Matrix derivation and approach +- https://www.itu.int/rec/T-REC-H.Sup18-201710-I +""" +from __future__ import annotations +import math +from .. import util +from ..channels import Channel +from ..types import Vector +from ..spaces import Luminant, Prism, Labish, Space +from ..cat import WHITES +from .. import algebra as alg + +BT709 = (0.2126, 0.0722) + + +class Environment: + """Environment.""" + + def __init__( + self, + *, + k: tuple[float, float] = BT709, + standard: bool = False, + bit_depth: int = 8 + ) -> None: + """Initialize.""" + + # Construct the transformation matrix + kr, kb = k + kg = 1 - kr - kb + self.rgb_to_ycbcr = [ + [kr, kg, kb], + [-kr / (2 * (1 - kb)), -kg / (2 * (1 - kb)), 0.5], + [0.5, -kg / (2 * (1 - kr)), -kb / (2 * (1 - kr))] + ] + self.ycbcr_to_rgb = alg.inv(self.rgb_to_ycbcr) + + self.max_integer_size = (1 << bit_depth) - 1 + self.standard = standard + + # Standard form which removes negative values and adds headroom/footroom + if standard: + self.y_scale = 219 * (1 << (bit_depth - 8)) # type: float + self.y_offset = 1 << (bit_depth - 4) # type: float + self.c_scale = 224 * (1 << (bit_depth - 8)) # type: float + self.c_offset = 1 << (bit_depth - 1) # type: float + + # Removes negative values but extends values to full range without adding headroom/footroom + # The default form cannot be in unsigned integer form and must be shifted + else: + self.y_scale = self.max_integer_size + self.y_offset = 0 + self.c_scale = self.max_integer_size + self.c_offset = 1 << (bit_depth - 1) + + # Calculate minimum and maximum ranges for color channels + self.y_range = [self.y_offset + 0 * self.y_scale, self.y_offset + 1 * self.y_scale] + self.c_range = [self.c_offset + -0.5 * self.c_scale, self.c_offset + 0.5 * self.c_scale] + self.c_middle = self.digital_round(self.c_range[0] + (self.c_range[1] - self.c_range[0]) / 2) + + def digital_round(self, x: float) -> int: + """ + Apply digital rounding and clamp to integer range. + + Rounding is applied such that half values are rounding towards positive or negative infinity + depending on the number's sign. Rounding defined in ITU-R BT.2100. + + - https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.2100-3-202502-I!!PDF-E.pdf + + Clamping is then applied to keep the value within the target integer type. + """ + + return alg.clamp(int(alg.sign(x) * math.floor(abs(x) + 0.5)), 0, self.max_integer_size) + + +class YPbPr(Labish, Space): + """YPbPr class.""" + + ENV: Environment + CHANNELS = ( + Channel("y", 0.0, 1.0, bound=True), + Channel("cb", -0.5, 0.5, bound=True), + Channel("cr", -0.5, 0.5, bound=True) + ) + CHANNEL_ALIASES = { + 'luma': 'y' + } + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "y" + + def is_achromatic(self, coords: Vector) -> bool: + """Check if color is achromatic.""" + + return alg.rect_to_polar(coords[1], coords[2])[0] < util.ACHROMATIC_THRESHOLD_SM + + def to_base(self, coords: Vector) -> Vector: + """To base from oRGB.""" + + return alg.matmul(self.ENV.ycbcr_to_rgb, coords, dims=alg.D2_D1) + + def from_base(self, coords: Vector) -> Vector: + """From base to oRGB.""" + + return alg.matmul(self.ENV.rgb_to_ycbcr, coords, dims=alg.D2_D1) + + +class YCbCr(Luminant, Prism, Space): + """Y'CbCr color class.""" + + ENV: Environment + + CHANNEL_ALIASES = { + 'lightness': 'y' + } + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "y" + + def is_achromatic(self, coords: Vector) -> bool: + """Check if color is achromatic.""" + + return coords[1] == coords[2] == self.ENV.c_middle + + def to_base(self, coords: Vector) -> Vector: + """To base from oRGB.""" + + env = self.ENV + co = env.c_offset + cs = env.c_scale + coords = [ + (coords[0] - env.y_offset) / env.y_scale, + (coords[1] - co) / cs, + (coords[2] - co) / cs, + ] + coords = alg.matmul(env.ycbcr_to_rgb, coords, dims=alg.D2_D1) + int_size = env.max_integer_size + coords = [env.digital_round(c * int_size) / int_size for c in coords] + return coords + + def from_base(self, coords: Vector) -> Vector: + """From base to oRGB.""" + + env = self.ENV + co = env.c_offset + cs = env.c_scale + coords = alg.matmul(env.rgb_to_ycbcr, coords, dims=alg.D2_D1) + coords = [ + env.y_offset + coords[0] * env.y_scale, + co + coords[1] * cs, + co + coords[2] * cs, + ] + return [env.digital_round(c) for c in coords] + + +class YPbPr709(YPbPr): + """YPbPr color class using Rec. 709.""" + + BASE = 'rec709' + NAME = "ypbpr709" + SERIALIZE = ("--ypbpr709",) + WHITE = WHITES['2deg']['D65'] + ENV = Environment(k=BT709) + GAMUT_CHECK = 'rec709' + + +class YCbCr709Bit8(YCbCr): + """YCbCr color class using Rec. 709 (8 bit).""" + + BASE = 'rec709' + NAME = "ycbcr709-8bit" + SERIALIZE = ("--ycbcr709-8bit",) + WHITE = WHITES['2deg']['D65'] + ENV = Environment(bit_depth=8, standard=True) + K = BT709 + CHANNELS = ( + Channel("y", ENV.y_range[0], ENV.y_range[1], nans=ENV.y_range[0], bound=True, limit=ENV.digital_round), + Channel("cb", ENV.c_range[0], ENV.c_range[1], nans=ENV.c_range[0], bound=True, limit=ENV.digital_round), + Channel("cr", ENV.c_range[0], ENV.c_range[1], nans=ENV.c_range[0], bound=True, limit=ENV.digital_round) + ) + GAMUT_CHECK = 'rec709' + + +class YCbCr709Bit10(YCbCr): + """YCbCr color class using Rec. 709 (10 bit).""" + + BASE = 'rec709' + NAME = "ycbcr709-10bit" + SERIALIZE = ("--ycbcr709-10bit",) + WHITE = WHITES['2deg']['D65'] + ENV = Environment(bit_depth=10, standard=True) + K = BT709 + CHANNELS = ( + Channel("y", ENV.y_range[0], ENV.y_range[1], nans=ENV.y_range[0], bound=True, limit=ENV.digital_round), + Channel("cb", ENV.c_range[0], ENV.c_range[1], nans=ENV.c_range[0], bound=True, limit=ENV.digital_round), + Channel("cr", ENV.c_range[0], ENV.c_range[1], nans=ENV.c_range[0], bound=True, limit=ENV.digital_round) + ) + GAMUT_CHECK = 'rec709-oetf' + diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt index cc46cf84e..9c6ccf9e6 100644 --- a/docs/src/dictionary/en-custom.txt +++ b/docs/src/dictionary/en-custom.txt @@ -244,7 +244,10 @@ Wz XD XYB XYZ +Y'CbCr YCbCr +YCbCr +YPbPr Yoshi ZCAM ZD @@ -297,10 +300,12 @@ easings electro emissive fixup +footroom formatter gradians grayscale hashable +headroom helixes hz illum @@ -364,6 +369,7 @@ rgb sCAM sRGB sUCS +sYCC seperable severities smoothstep diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md index 09b7b375b..54dbf3a5c 100644 --- a/docs/src/markdown/about/changelog.md +++ b/docs/src/markdown/about/changelog.md @@ -97,6 +97,7 @@ icon: lucide/scroll-text `to_string()`. - **NEW**: `Channel` object's `limit` parameter can now accept a function to constrain a channel. This can allow for more complex boundary constraints, such as rounding the value in addition to channels with a hard boundary ranges. +- **NEW**: Add the `sycc`, `ycbcr-709`, and `ycbcr-2020` color space. - **NEW**: CAM16, CAM02, HCT, ZCAM, and Hellwig will no longer force colors to black when lightness is zero except when chroma/saturation/colorfulness is also zero. This allows out of gamut colors with lightness of zero to properly be seen as out of gamut. diff --git a/docs/src/markdown/colors/index.md b/docs/src/markdown/colors/index.md index 65ce4b9df..18061db27 100644 --- a/docs/src/markdown/colors/index.md +++ b/docs/src/markdown/colors/index.md @@ -43,6 +43,7 @@ flowchart LR xyz-d65 --- srgb-linear srgb-linear --- rec709 srgb-linear --- rec709-oetf + rec709-oetf --- ycbcr-709 srgb-linear --- srgb srgb --- hsl srgb --- hsv @@ -55,12 +56,14 @@ flowchart LR srgb --- hsi srgb --- orgb srgb --- prismatic + srgb --- sycc xyz-d65 --- display-p3-linear --- display-p3 xyz-d65 --- rec2020-linear rec2020-linear --- rec2020 rec2020-linear --- rec2020-oetf + rec2020-oetf --- ycbcr-2020 rec2020-linear --- rec2100-linear rec2100-linear --- rec2100-pq rec2100-linear --- rec2100-hlg @@ -198,6 +201,9 @@ flowchart LR xyz-d50(XYZ D50) xyz-d65(XYZ D65) zcam-jmh(ZCAM JMh) + sycc(sYCC) + ycbcr-709(Y'CbCr ITU-R BT.709) + ycbcr-2020(Y'CbCr ITU-R BT.2020) click a98-rgb "./a98_rgb/" _self click a98-rgb-linear "./a98_rgb_linear/" _self @@ -271,6 +277,9 @@ flowchart LR click xyz-d50 "./xyz_d50/" _self click xyz-d65 "./xyz_d65/" _self click zcam-jmh "./zcam/" _self + click sycc "./sycc/" _self + click ycbcr-709 "./ycbcr_709/" _self + click ycbcr-2020 "./ycbcr-2020/" _self ``` /// @@ -347,9 +356,12 @@ Color Space | ID [sRGB](./srgb.md) | `srgb` [sCAM JMh](./scam.md) | `scam-jmh` [sUCS](./sucs.md) | `sucs` +[sYCC](./sycc.md) | `sycc` [UCS](./ucs.md) | `ucs` [XYB](./xyb.md) | `xyb` [xyY](./xyy.md) | `xyy` [XYZ (D50)](./xyz_d50.md) | `xyz-d50` [XYZ (D65)](./xyz_d65.md) | `xyz-d65` +[Y'CbCr ITU-R BT.2020](./ycbcr_2020) | `ycbcr-2020` +[Y'CbCr ITU-R BT.709](./ycbcr_709) | `ycbcr-709` [ZCAM JMh](./zcam.md) | `zcam-jmh` diff --git a/docs/src/markdown/images/sycc-3d.png b/docs/src/markdown/images/sycc-3d.png new file mode 100644 index 000000000..6413dfc22 Binary files /dev/null and b/docs/src/markdown/images/sycc-3d.png differ diff --git a/docs/src/markdown/images/sycc-8bit-3d.png b/docs/src/markdown/images/sycc-8bit-3d.png new file mode 100644 index 000000000..db7e84927 Binary files /dev/null and b/docs/src/markdown/images/sycc-8bit-3d.png differ diff --git a/docs/src/markdown/images/ycbcr2020-10bit-3d.png b/docs/src/markdown/images/ycbcr2020-10bit-3d.png new file mode 100644 index 000000000..9848beaf5 Binary files /dev/null and b/docs/src/markdown/images/ycbcr2020-10bit-3d.png differ diff --git a/docs/src/markdown/images/ycbcr709-10bit-3d.png b/docs/src/markdown/images/ycbcr709-10bit-3d.png new file mode 100644 index 000000000..eff9ca70b Binary files /dev/null and b/docs/src/markdown/images/ycbcr709-10bit-3d.png differ diff --git a/docs/src/markdown/images/ycbcr709-8bit-3d.png b/docs/src/markdown/images/ycbcr709-8bit-3d.png new file mode 100644 index 000000000..31c01734a Binary files /dev/null and b/docs/src/markdown/images/ycbcr709-8bit-3d.png differ diff --git a/docs/src/markdown/images/ypbpr2020-3d.png b/docs/src/markdown/images/ypbpr2020-3d.png new file mode 100644 index 000000000..e06c1635e Binary files /dev/null and b/docs/src/markdown/images/ypbpr2020-3d.png differ diff --git a/docs/src/markdown/images/ypbpr709-3d.png b/docs/src/markdown/images/ypbpr709-3d.png new file mode 100644 index 000000000..f8dd142ff Binary files /dev/null and b/docs/src/markdown/images/ypbpr709-3d.png differ diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py index eeae8ed5d..37537030c 100644 --- a/tests/test_roundtrip.py +++ b/tests/test_roundtrip.py @@ -19,16 +19,23 @@ class TestRoundTrip: class Color(Base): """Local color object.""" + # These spaces don't have a gamut large enough to have good round trip throughout sRGB Color.deregister('space:hpluv') + # These spaces, since they are digital, don't have the resolution to have good round trip throughout sRGB + Color.deregister('space:sycc-8bit') + Color.deregister('space:ycbcr709-8bit') + Color.deregister('space:ycbcr709-10bit') + Color.deregister('space:ycbcr2020-10bit') + Color.deregister('space:ycbcr2020-12bit') SPACES = dict.fromkeys(Color.CS_MAP, 10) # HCT will actually provide good conversion at about 11 decimal precision, # but certain spaces have a gamma power function that is less accurate with RGB channels near zero # (not necessarily near black): Rec. 2020, Rec. 709, etc. HCT, which approximates its inverse transform, - # can emphasizes these cases and fall a little below our target of 6. This is expected. + # can exasperate these cases and fall a little below our target of 6. This is expected. # To handle this in testing, we can specify more nuanced conditions. Provide the default precision, # and optional lower precision, and spaces that would trigger this lower precision. - SPACES['hct'] = (10, 4, {'rec709', 'rec2020', 'a98-rgb'}) + SPACES['hct'] = (10, 4, {'rec709', 'rec2020', 'a98-rgb', 'ypbpr709', 'ypbpr2020'}) COLORS = [ Color('red'), @@ -98,9 +105,15 @@ class TestAchromaticRoundTrip(TestRoundTrip): class Color(Base): """Local color object.""" - Color.deregister('space:hpluv') + # These spaces don't have a gamut large enough to have good round trip throughout sRGB Color.deregister('space:ryb') Color.deregister('space:ryb-biased') + # These spaces, since they are digital, don't have the resolution to have good round trip throughout sRGB + Color.deregister('space:sycc-8bit') + Color.deregister('space:ycbcr709-8bit') + Color.deregister('space:ycbcr709-10bit') + Color.deregister('space:ycbcr2020-10bit') + Color.deregister('space:ycbcr2020-12bit') SPACES = dict.fromkeys(Color.CS_MAP, 10) diff --git a/tools/gen_3d_models.py b/tools/gen_3d_models.py index 16f07b9b4..85644925d 100644 --- a/tools/gen_3d_models.py +++ b/tools/gen_3d_models.py @@ -80,11 +80,33 @@ def plot_model(name, title, filename, gamut='srgb', elev=45, azim=-60.0): 'scam-jmh': {'title': TEMPLATE.format('sCAM JMh'), 'filename': 'scam-jmh-3d.png', 'azim': 320}, 'srgb': {'title': 'sRGB Color Space', 'filename': 'srgb-3d.png'}, 'sucs': {'title': TEMPLATE.format('sUCS'), 'filename': 'sucs-3d.png', 'azim': 320}, + 'sycc': {'title': TEMPLATE.format('sYCC'), 'filename': 'sycc-3d.png', 'gamut': 'srgb'}, + 'sycc-8bit': {'title': TEMPLATE.format('sYCC (8 Bit)'), 'filename': 'sycc-8bit-3d.png', 'gamut': 'srgb'}, 'ucs': {'title': TEMPLATE.format('UCS'), 'filename': 'ucs-3d.png', 'azim': 60, 'elev': 10}, 'xyb': {'title': TEMPLATE.format('XYB'), 'filename': 'xyb-3d.png', 'azim': 45}, 'xyy': {'title': TEMPLATE.format('xyY'), 'filename': 'xyy-3d.png'}, 'xyz-d50': {'title': TEMPLATE.format('XYZ D50'), 'filename': 'xyz-d50-3d.png'}, 'xyz-d65': {'title': TEMPLATE.format('XYZ D65'), 'filename': 'xyz-d65-3d.png'}, + 'ycbcr2020-10bit': { + 'title': "Rec. 2020 Gamut Plotted in YCbCr (10 BIT) ITU-R BT.2020 Color Space", + 'filename': 'ycbcr2020-10bit-3d.png', 'gamut': 'rec2020' + }, + 'ycbcr709-10bit': { + 'title': "Rec. 709 Gamut Plotted in Y'CbCr (10 BIT) ITU-R BT.709 Color Space", + 'filename': 'ycbcr709-10bit-3d.png', 'gamut': 'rec709' + }, + 'ycbcr709-8bit': { + 'title': "Rec. 709 Gamut Plotted in YCbCr (8 BIT) ITU-R BT.709 Color Space", + 'filename': 'ycbcr709-8bit-3d.png', 'gamut': 'rec709' + }, + 'ypbpr2020': { + 'title': "Rec. 2020 Gamut Plotted in YPbPr ITU-R BT.2020 Color Space", + 'filename': 'ypbpr2020-3d.png', 'gamut': 'rec2020' + }, + 'ypbpr709': { + 'title': "Rec. 709 Gamut Plotted in YPbPr ITU-R BT.709 Color Space", + 'filename': 'ypbpr709-3d.png', 'gamut': 'rec709' + }, 'zcam-jmh': {'title': TEMPLATE.format('ZCAM JMh'), 'filename': 'zcam-jmh-3d.png', 'azim': 320} }