Skip to content

Commit

Permalink
Merge pull request #331 from facelessuser/feature/pointer
Browse files Browse the repository at this point in the history
Add support for Pointer gamut
  • Loading branch information
facelessuser authored Jun 26, 2023
2 parents 4bb07fd + e076063 commit a6116a5
Show file tree
Hide file tree
Showing 40 changed files with 1,586 additions and 253 deletions.
4 changes: 4 additions & 0 deletions coloraide/algebra.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ def round_to(f: float, p: int = 0) -> float:
elif p == 0:
return round_half_up(f)

# Ignore infinity
elif math.isinf(f):
return f

# Round to the specified precision
else:
whole = int(f)
Expand Down
230 changes: 187 additions & 43 deletions coloraide/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import functools
import random
import math
from . import cat
from . import distance
from . import convert
from . import gamut
Expand Down Expand Up @@ -69,6 +70,8 @@
from .types import Plugin
from typing import overload, Sequence, Iterable, Any, Callable, Mapping

SUPPORTED_CHROMATICITY_SPACES = set(('xyz', 'uv-1960', 'uv-1976', 'xy-1931'))


class ColorMatch:
"""Color match object."""
Expand Down Expand Up @@ -477,11 +480,12 @@ def random(cls, space: str, *, limits: Sequence[Sequence[float] | None] | None =
@classmethod
def blackbody(
cls,
space: str,
temp: float,
duv: float = 0.0,
*,
space: str | None = 'srgb-linear',
out_space: str | None = None,
scale: bool = True,
scale_space: str | None = None,
method: str | None = None,
**kwargs: Any
) -> Color:
Expand All @@ -499,29 +503,7 @@ def blackbody(
"""

cct = temperature.cct(method, cls)

if out_space is None:
out_space = space or 'xyz-d65'

color = cct.from_cct(cls, temp, duv, **kwargs)

# Normalize in the given RGB color space (ideally linear).
if space is not None and isinstance(cls.CS_MAP[space], RGBish):
color.convert(space, in_place=True)
coords = color.coords()

# Add just enough white to make r, g, b all positive.
w = -min(0.0, *coords)
if w:
coords = [c + w for c in coords]

# Normalize values such that the maximum is 1 (unless all are zero)
m = max(coords)
color[:-1] = [c / m for c in coords] if m else coords

if out_space != color.space():
color.convert(out_space, in_place=True)

color = cct.from_cct(cls, space, temp, duv, scale, scale_space, **kwargs)
return color

def cct(self, *, method: str | None = None, **kwargs: Any) -> Vector:
Expand Down Expand Up @@ -678,38 +660,178 @@ def __repr__(self) -> str:

__str__ = __repr__

def white(self) -> Vector:
def white(self, cspace: str = 'xyz') -> Vector:
"""Get the white point."""

return util.xy_to_xyz(self._space.white())
value = self.convert_chromaticity('xy-1931', cspace, self._space.WHITE)
return value if cspace == 'xyz' else value[:-1]

def uv(self, mode: str = '1976') -> Vector:
def uv(self, mode: str = '1976', *, white: VectorLike | None = None) -> Vector:
"""Convert to `xy`."""

if mode == '1976':
uv = util.xy_to_uv(self.xy())
elif mode == '1960':
uv = util.xy_to_uv_1960(self.xy())
else:
raise ValueError("'mode' must be either '1960' or '1976' (default), not '{}'".format(mode))
return uv
return self.split_chromaticity('uv-' + mode)[:-1]

def xy(self) -> Vector:
def xy(self, *, white: VectorLike | None = None) -> Vector:
"""Convert to `xy`."""

return self.split_chromaticity('xy-1931')[:-1]

def split_chromaticity(
self,
cspace: str = 'uv-1976',
*,
white: VectorLike | None = None
) -> Vector:
"""
Split a color into chromaticity and luminance coordinates.
Colors are split under the XYZ color space using the current color's white point.
If results are desired relative to a different white point, one can be provided.
"""

if white is None:
white = self._space.WHITE

# Convert to XYZ D65 as it is a color space that is always required.
# Chromatically adapt it to the XYZ color space with the current color's white point.
xyz = self.convert('xyz-d65')
coords = self.chromatic_adaptation(
xyz._space.WHITE,
self._space.WHITE,
white,
xyz.coords(nans=False)
)
return util.xyz_to_xyY(coords, self._space.white())[:2]

# XYZ is not a chromaticity space
if cspace == 'xyz':
raise ValueError('XYZ is not a luminant-chromaticity color space.')

# Convert to the the requested uv color space if required.
return (
self.convert_chromaticity('xyz', cspace, coords, white=white) if cspace != 'xy_1931' else coords
)

@classmethod
def chromaticity(
cls,
space: str,
coords: VectorLike,
cspace: str = 'uv-1976',
*,
scale: bool = False,
scale_space: str | None = None,
white: VectorLike | None = None
) -> Color:
"""
Create a color from chromaticity coordinates.
A luminance of 1 will be assumed unless luminance is included with the coordinates.
The relative white point of the chromaticity coordinates will be assumed as the
targeted color space unless one is provided via `white`.
Lastly, colors can be scaled/normalized within a linear RGB space to normalize
luminance and provide a nice viewable color. This is useful when the luminance is
not accurate (such as when luminance is assumed 1). Colors that are out of the linear
RGB space's gamut will only be rough approximations of the color due to gamut
limitations. Default linear RGB space is linear sRGB.
"""

if scale_space is None:
scale_space = 'srgb-linear'

# Use the white point of the target color space unless a white point is given.
if white is None:
white = cls.CS_MAP[space].WHITE

# XYZ is not a chromaticity space
if cspace == 'xyz':
raise ValueError('XYZ is not a luminant-chromaticity color space.')

coords = cls.convert_chromaticity(cspace, 'xyz', coords, white=white)

# Apply chromatic adaptation to match XYZ D65 white point
color = cls(
'xyz-d65',
cls.chromatic_adaptation(white, cls.CS_MAP['xyz-d65'].WHITE, coords)
)

# Normalize in the given RGB color space (ideally linear).
if scale and isinstance(cls.CS_MAP[scale_space], RGBish):
color.convert(scale_space, in_place=True)
color[:-1] = util.rgb_scale(color.coords())

# Convert to targeted color space
if space != color.space():
color.convert(space, in_place=True)

return color

@classmethod
def convert_chromaticity(
cls, cspace1: str,
cspace2: str,
coords: VectorLike,
*,
white: VectorLike | None = None
) -> Vector:
"""
Convert to or from chromaticity coordinates or between other chromaticity coordinates.
When converting to or from chromaticity coordinates, the coordinates must be in the XYZ space.
A white point can be provided and only serves to align colors like black on the achromatic axis;
otherwise, black will be returned as [0, 0] for the two respective chromaticity points.
"""

# Check that we know the requested spaces
if cspace1 not in SUPPORTED_CHROMATICITY_SPACES:
raise ValueError("Unexpected chromaticity space '{}'".format(cspace1))
if cspace2 not in SUPPORTED_CHROMATICITY_SPACES:
raise ValueError("Unexpected chromaticity space '{}'".format(cspace2))

# Return if there is nothing to convert
l = len(coords)
if (cspace1 == 'xyz' and l != 3) or l not in (2, 3):
raise ValueError('Unexpected number of coordinates ({}) for {}'.format(l, cspace1))

# Return if already in desired form
if cspace1 == cspace2:
return list(coords) + [1] if l == 2 else list(coords)

# If starting space is XYZ, then convert to xy
if cspace1 == 'xyz':
coords = util.xyz_to_xyY(coords, [0.0] * 2 if white is None else white)
cspace1 = 'xy-1931'

# If the end space is xy, we have nothing else to do
if cspace2 == cspace1:
return coords

# If we have no luminance, assume 1
pair, Y = (coords[:-1], coords[-1]) if l == 3 else (coords, 1.0)

# If we are targeting XYZ, force conversion to xy first.
target = cspace2
if cspace2 == 'xyz':
cspace2 = 'xy-1931'

# Perform conversion
if cspace1 == 'xy-1931' and cspace2 != 'xy-1931':
pair = util.xy_to_uv_1960(pair) if cspace2 == 'uv-1960' else util.xy_to_uv(pair)
elif cspace1 == 'uv-1960':
pair = util.uv_1960_to_xy(pair) if cspace2 == 'xy-1931' else util.xy_to_uv(util.uv_1960_to_xy(pair))
elif cspace1 == 'uv-1976':
pair = util.uv_to_xy(pair) if cspace2 == 'xy-1931' else util.xy_to_uv_1960(util.uv_to_xy(pair))

# Special case to convert to XYZ from xy
if target == 'xyz':
return util.xy_to_xyz(pair, Y)

return list(pair) + [Y]

@classmethod
def chromatic_adaptation(
cls,
w1: tuple[float, float],
w2: tuple[float, float],
w1: VectorLike,
w2: VectorLike,
xyz: VectorLike,
*,
method: str | None = None
Expand All @@ -720,7 +842,7 @@ def chromatic_adaptation(
if not adapter:
raise ValueError("'{}' is not a supported CAT".format(method))

return adapter.adapt(w1, w2, xyz)
return adapter.adapt(tuple(w1), tuple(w2), xyz) # type: ignore[arg-type]

def clip(self, space: str | None = None) -> Color:
"""Clip the color channels."""
Expand Down Expand Up @@ -796,6 +918,16 @@ def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_

return gamut.verify(c, tolerance)

def in_pointer_gamut(self, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool:
"""Check if in pointer gamut."""

return gamut.pointer.in_pointer_gamut(self, tolerance)

def fit_pointer_gamut(self) -> Color:
"""Check if in pointer gamut."""

return gamut.pointer.fit_pointer_gamut(self)

def mask(self, channel: str | Sequence[str], *, invert: bool = False, in_place: bool = False) -> Color:
"""Mask color channels."""

Expand Down Expand Up @@ -998,10 +1130,22 @@ def closest(

return distance.closest(self, colors, method=method, **kwargs)

def luminance(self) -> float:
def luminance(self, *, white: VectorLike = cat.WHITES['2deg']['D65']) -> float:
"""Get color's luminance."""

return self.convert("xyz-d65").get('y', nans=False)
if white is None:
white = self._space.WHITE

# Convert to XYZ D65 as it is a color space that is always required.
# Chromatically adapt it to the XYZ color space with the current color's white point.
xyz = self.convert('xyz-d65')
coords = self.chromatic_adaptation(
xyz._space.WHITE,
white,
xyz.coords(nans=False)
)

return coords[1]

def contrast(self, color: ColorInput, method: str | None = None) -> float:
"""Compare the contrast ratio of this color and the provided color."""
Expand Down
3 changes: 3 additions & 0 deletions coloraide/gamut/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
from ..types import Plugin
from typing import TYPE_CHECKING, Any
from .. import util
from . import pointer

if TYPE_CHECKING: # pragma: no cover
from ..color import Color

__all__ = ('clip_channels', 'verify', 'Fit', 'pointer')


def clip_channels(color: Color, nans: bool = True) -> None:
"""Clip channels."""
Expand Down
Loading

0 comments on commit a6116a5

Please sign in to comment.