Skip to content
Draft
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
11 changes: 11 additions & 0 deletions coloraide/everything.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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
Expand Down Expand Up @@ -107,6 +110,14 @@ class ColorAll(Base):
ZCAMJMh(),
Rec2020OETF(),
Msh(),
sYCC(),
YPbPr709(),
YPbPr2020(),
sYCC8(),
YCbCr709Bit8(),
YCbCr709Bit10(),
YCbCr2020Bit10(),
YCbCr2020Bit12(),

# Delta E
DE99o(),
Expand Down
42 changes: 42 additions & 0 deletions coloraide/spaces/sycc.py
Original file line number Diff line number Diff line change
@@ -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'
55 changes: 55 additions & 0 deletions coloraide/spaces/ycbcr2020.py
Original file line number Diff line number Diff line change
@@ -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-oetf'
NAME = "ypbpr2020"
SERIALIZE = ("--ypbpr2020",)
WHITE = WHITES['2deg']['D65']
ENV = Environment(k=BT2020)
GAMUT_CHECK = 'rec2020-oetf'


class YCbCr2020Bit10(YCbCr):
"""Y'CbCr color class using Rec. 2020 (10 bit)."""

BASE = 'rec2020-oetf'
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-oetf'


class YCbCr2020Bit12(YCbCr):
"""Y'CbCr color class using Rec. 2020 (12 bit)."""

BASE = 'rec2020-oetf'
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-oetf'
209 changes: 209 additions & 0 deletions coloraide/spaces/ycbcr709.py
Original file line number Diff line number Diff line change
@@ -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-oetf'
NAME = "ypbpr709"
SERIALIZE = ("--ypbpr709",)
WHITE = WHITES['2deg']['D65']
ENV = Environment(k=BT709)
GAMUT_CHECK = 'rec709-oetf'


class YCbCr709Bit8(YCbCr):
"""YCbCr color class using Rec. 709 (8 bit)."""

BASE = 'rec709-oetf'
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-oetf'


class YCbCr709Bit10(YCbCr):
"""YCbCr color class using Rec. 709 (10 bit)."""

BASE = 'rec709-oetf'
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'

4 changes: 4 additions & 0 deletions docs/src/dictionary/en-custom.txt
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ Wz
XD
XYB
XYZ
Y'CbCr
YCbCr
YCbCr
Yoshi
ZCAM
Expand Down Expand Up @@ -295,9 +297,11 @@ easings
electro
emissive
fixup
footroom
formatter
gradians
grayscale
headroom
helixes
hz
illum
Expand Down
1 change: 1 addition & 0 deletions docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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.
Expand Down
Loading
Loading