From 4e86272a5189cb01d1a92f429731e16dbcd569d8 Mon Sep 17 00:00:00 2001 From: Isaac Muse Date: Sat, 18 May 2024 16:13:57 -0600 Subject: [PATCH] Upgrade coloraide (#267) * Upgrade coloraide * Upgrade to latest coloraide * Update build configs * Fix lint issues * make note of new gamut map method * Update color space list in settings --- .github/workflows/build.yml | 6 +- .github/workflows/deploy.yml | 2 +- CHANGES.md | 7 + ch_picker.py | 4 +- color_helper.sublime-settings | 9 +- custom/ahex.py | 6 +- custom/ass_abgr.py | 6 +- custom/hex_0x.py | 8 +- custom/st_colormod.py | 14 +- custom/tmtheme.py | 6 +- docs/src/markdown/settings/previews.md | 2 +- lib/coloraide/__meta__.py | 5 +- lib/coloraide/algebra.py | 912 ++++++++++++------- lib/coloraide/average.py | 19 +- lib/coloraide/cat.py | 41 +- lib/coloraide/channels.py | 20 +- lib/coloraide/color.py | 343 ++++--- lib/coloraide/compositing/__init__.py | 35 +- lib/coloraide/compositing/blend_modes.py | 4 +- lib/coloraide/compositing/porter_duff.py | 4 +- lib/coloraide/contrast/__init__.py | 7 +- lib/coloraide/contrast/lstar.py | 3 +- lib/coloraide/contrast/wcag21.py | 3 +- lib/coloraide/convert.py | 18 +- lib/coloraide/css/color_names.py | 10 +- lib/coloraide/css/parse.py | 41 +- lib/coloraide/css/serialize.py | 135 +-- lib/coloraide/deprecate.py | 5 +- lib/coloraide/distance/__init__.py | 31 +- lib/coloraide/distance/delta_e_2000.py | 63 +- lib/coloraide/distance/delta_e_76.py | 24 +- lib/coloraide/distance/delta_e_94.py | 29 +- lib/coloraide/distance/delta_e_99o.py | 17 +- lib/coloraide/distance/delta_e_cam16.py | 36 +- lib/coloraide/distance/delta_e_cmc.py | 26 +- lib/coloraide/distance/delta_e_hct.py | 30 +- lib/coloraide/distance/delta_e_hyab.py | 5 +- lib/coloraide/distance/delta_e_itp.py | 5 +- lib/coloraide/distance/delta_e_ok.py | 17 +- lib/coloraide/distance/delta_e_z.py | 3 +- lib/coloraide/easing.py | 19 +- lib/coloraide/everything.py | 19 +- lib/coloraide/filters/__init__.py | 17 +- lib/coloraide/filters/cvd.py | 45 +- lib/coloraide/filters/w3c_filter_effects.py | 29 +- lib/coloraide/gamut/__init__.py | 44 +- lib/coloraide/gamut/fit_hct_chroma.py | 6 +- lib/coloraide/gamut/fit_lch_chroma.py | 48 +- lib/coloraide/gamut/fit_lch_raytrace.py | 9 + lib/coloraide/gamut/fit_oklch_chroma.py | 2 + lib/coloraide/gamut/fit_oklch_raytrace.py | 9 + lib/coloraide/gamut/fit_raytrace.py | 269 ++++++ lib/coloraide/gamut/pointer.py | 21 +- lib/coloraide/harmonies.py | 124 +-- lib/coloraide/interpolate/__init__.py | 270 ++---- lib/coloraide/interpolate/bspline.py | 39 +- lib/coloraide/interpolate/bspline_natural.py | 40 +- lib/coloraide/interpolate/catmull_rom.py | 40 +- lib/coloraide/interpolate/continuous.py | 157 +++- lib/coloraide/interpolate/css_linear.py | 91 ++ lib/coloraide/interpolate/linear.py | 102 ++- lib/coloraide/interpolate/monotone.py | 40 +- lib/coloraide/spaces/__init__.py | 146 +-- lib/coloraide/spaces/a98_rgb.py | 16 +- lib/coloraide/spaces/a98_rgb_linear.py | 11 +- lib/coloraide/spaces/aces2065_1.py | 12 +- lib/coloraide/spaces/acescc.py | 13 +- lib/coloraide/spaces/acescct.py | 13 +- lib/coloraide/spaces/acescg.py | 12 +- lib/coloraide/spaces/achromatic.py | 190 ---- lib/coloraide/spaces/cam16.py | 85 -- lib/coloraide/spaces/cam16_jmh.py | 318 ++----- lib/coloraide/spaces/cam16_ucs.py | 89 +- lib/coloraide/spaces/cmy.py | 10 +- lib/coloraide/spaces/cmyk.py | 8 +- lib/coloraide/spaces/cubehelix.py | 13 +- lib/coloraide/spaces/din99o.py | 11 +- lib/coloraide/spaces/display_p3.py | 13 +- lib/coloraide/spaces/display_p3_linear.py | 11 +- lib/coloraide/spaces/hct.py | 230 ++--- lib/coloraide/spaces/hpluv.py | 40 +- lib/coloraide/spaces/hsi.py | 14 +- lib/coloraide/spaces/hsl/__init__.py | 30 +- lib/coloraide/spaces/hsl/css.py | 28 +- lib/coloraide/spaces/hsluv.py | 37 +- lib/coloraide/spaces/hsv.py | 30 +- lib/coloraide/spaces/hunter_lab.py | 5 +- lib/coloraide/spaces/hwb/__init__.py | 9 +- lib/coloraide/spaces/hwb/css.py | 18 +- lib/coloraide/spaces/ictcp.py | 36 +- lib/coloraide/spaces/igpgtg.py | 69 +- lib/coloraide/spaces/ipt.py | 73 +- lib/coloraide/spaces/jzazbz.py | 232 +---- lib/coloraide/spaces/jzczhz.py | 42 +- lib/coloraide/spaces/lab/__init__.py | 52 +- lib/coloraide/spaces/lab/css.py | 17 +- lib/coloraide/spaces/lab_d65.py | 5 +- lib/coloraide/spaces/lch/__init__.py | 44 +- lib/coloraide/spaces/lch/css.py | 17 +- lib/coloraide/spaces/lch99o.py | 3 +- lib/coloraide/spaces/lch_d65.py | 7 +- lib/coloraide/spaces/lchuv.py | 3 +- lib/coloraide/spaces/luv.py | 6 +- lib/coloraide/spaces/okhsl.py | 42 +- lib/coloraide/spaces/okhsv.py | 16 +- lib/coloraide/spaces/oklab/__init__.py | 17 +- lib/coloraide/spaces/oklab/css.py | 15 +- lib/coloraide/spaces/oklch/__init__.py | 7 +- lib/coloraide/spaces/oklch/css.py | 15 +- lib/coloraide/spaces/orgb.py | 9 +- lib/coloraide/spaces/prismatic.py | 10 +- lib/coloraide/spaces/prophoto_rgb.py | 12 +- lib/coloraide/spaces/prophoto_rgb_linear.py | 9 +- lib/coloraide/spaces/rec2020.py | 12 +- lib/coloraide/spaces/rec2020_linear.py | 9 +- lib/coloraide/spaces/rec2100_hlg.py | 21 +- lib/coloraide/spaces/rec2100_linear.py | 16 + lib/coloraide/spaces/rec2100_pq.py | 25 +- lib/coloraide/spaces/rec709.py | 10 +- lib/coloraide/spaces/rlab.py | 42 +- lib/coloraide/spaces/ryb.py | 3 +- lib/coloraide/spaces/srgb/__init__.py | 30 +- lib/coloraide/spaces/srgb/css.py | 15 +- lib/coloraide/spaces/srgb_linear.py | 31 +- lib/coloraide/spaces/ucs.py | 1 + lib/coloraide/spaces/xyb.py | 12 +- lib/coloraide/spaces/xyy.py | 9 +- lib/coloraide/spaces/xyz_d50.py | 1 + lib/coloraide/spaces/xyz_d65.py | 6 +- lib/coloraide/spaces/zcam_jmh.py | 449 +++++++++ lib/coloraide/temperature/__init__.py | 13 +- lib/coloraide/temperature/ohno_2013.py | 21 +- lib/coloraide/temperature/planck.py | 4 +- lib/coloraide/temperature/robertson_1968.py | 21 +- lib/coloraide/types.py | 20 +- lib/coloraide/util.py | 98 +- tox.ini | 2 +- 137 files changed, 3594 insertions(+), 2848 deletions(-) create mode 100644 lib/coloraide/gamut/fit_lch_raytrace.py create mode 100644 lib/coloraide/gamut/fit_oklch_raytrace.py create mode 100644 lib/coloraide/gamut/fit_raytrace.py create mode 100644 lib/coloraide/interpolate/css_linear.py delete mode 100644 lib/coloraide/spaces/achromatic.py delete mode 100644 lib/coloraide/spaces/cam16.py create mode 100644 lib/coloraide/spaces/rec2100_linear.py create mode 100644 lib/coloraide/spaces/zcam_jmh.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c9291d9..97c06269 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index db896787..a90f8712 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python diff --git a/CHANGES.md b/CHANGES.md index 93df731e..5b6cf18a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # ColorHelper +## 6.4 + +- **NEW**: Upgrade ColorAide. +- **NEW**: Note in documentation and settings a new gamut mapping + method, `oklch-raytrace`, which does a chroma reduction much + faster and closer than the current suggested CSS algorithm. + ## 6.3.2 - **FIX**: Fix missing requirement for `math.isclose` in ColorAide diff --git a/ch_picker.py b/ch_picker.py index 4fb77e66..a31f43b9 100644 --- a/ch_picker.py +++ b/ch_picker.py @@ -109,7 +109,7 @@ def get_color_map_square_hsv(self, mode='hsv'): global default_border global color_scale - hue, saturation, value = alg.no_nans(self.color.convert(mode)[:-1]) + hue, saturation, value = self.color.convert(mode).coords(nans=False) r_sat = saturation r_val = value @@ -248,7 +248,7 @@ def get_color_map_square(self, mode='hsl'): global default_border global color_scale - hue, saturation, lightness = alg.no_nans(self.color.convert(mode)[:-1]) + hue, saturation, lightness = self.color.convert(mode).coords(nans=False) r_sat = saturation r_lit = lightness diff --git a/color_helper.sublime-settings b/color_helper.sublime-settings index 48b67687..554df869 100755 --- a/color_helper.sublime-settings +++ b/color_helper.sublime-settings @@ -117,7 +117,7 @@ "gamut_space": "srgb", // Gamut mapping approach - // Supported methods are: `lch-chroma`, `oklch-chroma`, and `clip` (default). + // Supported methods are: `lch-chroma`, `oklch-chroma`, `oklch-raytrace`, and `clip` (default). // `lch-chroma` was the original default before this was configurable. "gamut_map": "clip", @@ -135,7 +135,6 @@ // "ColorHelper.lib.coloraide.spaces.acescc.ACEScc", // "ColorHelper.lib.coloraide.spaces.acescg.ACEScg", // "ColorHelper.lib.coloraide.spaces.acescct.ACEScct", - // "ColorHelper.lib.coloraide.spaces.cam16.CAM16", // "ColorHelper.lib.coloraide.spaces.cam16_jmh.CAM16JMh", // "ColorHelper.lib.coloraide.spaces.cam16_ucs.CAM16UCS", // "ColorHelper.lib.coloraide.spaces.cam16_ucs.CAM16SCD", @@ -149,7 +148,7 @@ // "ColorHelper.lib.coloraide.spaces.hunter_lab.HunterLab", // "ColorHelper.lib.coloraide.spaces.ictcp.ICtCp", // "ColorHelper.lib.coloraide.spaces.igtgpg.IgTgPg", - // "ColorHelper.lib.coloraide.spaces.itp.ITP", + // "ColorHelper.lib.coloraide.spaces.ipt.IPT", // "ColorHelper.lib.coloraide.spaces.jzazbz.Jzazbz", // "ColorHelper.lib.coloraide.spaces.jzczhz.JzCzhz", // "ColorHelper.lib.coloraide.spaces.lch99o.LCh99o", @@ -158,6 +157,7 @@ // "ColorHelper.lib.coloraide.spaces.rec2100_hlg.Rec2100HLG", // "ColorHelper.lib.coloraide.spaces.rec2100_pq.Rec2100PQ", // "ColorHelper.lib.coloraide.spaces.rlab.RLAB", + // "ColorHelper.lib.coloraide.spaces.ryb.RYB", // "ColorHelper.lib.coloraide.spaces.xyb.XYB", // "ColorHelper.lib.coloraide.spaces.xyy.xyY", "ColorHelper.lib.coloraide.spaces.hsluv.HSLuv", @@ -257,7 +257,8 @@ "output": [ {"space": "srgb", "format": {"hex": true}}, {"space": "srgb", "format": {"comma": true, "precision": 3}}, - {"space": "hsl", "format": {"comma": true, "precision": 3}} + {"space": "hsl", "format": {"comma": true, "precision": 3}}, + {"space": "hwb", "format": {"comma": true, "precision": 3}} ] }, "tmtheme": { diff --git a/custom/ahex.py b/custom/ahex.py index 6fc47418..eca3d7d3 100644 --- a/custom/ahex.py +++ b/custom/ahex.py @@ -36,8 +36,7 @@ class ASRGB(sRGB): COLOR_FORMAT = False - @classmethod - def match(cls, string, start=0, fullmatch=True): + def match(self, string, start=0, fullmatch=True): """Match a CSS color string.""" m = MATCH.match(string, start) @@ -45,9 +44,8 @@ def match(cls, string, start=0, fullmatch=True): return split_channels(m.group(0)), m.end(0) return None - @classmethod def to_string( - cls, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs + self, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs ): """Convert to Hex format.""" diff --git a/custom/ass_abgr.py b/custom/ass_abgr.py index 20d4eb3f..bb761ade 100644 --- a/custom/ass_abgr.py +++ b/custom/ass_abgr.py @@ -32,8 +32,7 @@ def split_channels(color: str): class AssABGR(sRGB): """ASS `ABGR` color space.""" - @classmethod - def match(cls, string: str, start: int = 0, fullmatch: bool = True): + def match(self, string: str, start: int = 0, fullmatch: bool = True): """Match a color string.""" m = MATCH.match(string, start) @@ -41,8 +40,7 @@ def match(cls, string: str, start: int = 0, fullmatch: bool = True): return split_channels(m.group("color")), m.end(0) return None - @classmethod - def to_string(cls, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs): + def to_string(self, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs): """Convert color to `&HAABBGGRR`.""" options = kwargs diff --git a/custom/hex_0x.py b/custom/hex_0x.py index 7afd4a91..2050510a 100644 --- a/custom/hex_0x.py +++ b/custom/hex_0x.py @@ -1,4 +1,4 @@ -"""Custon color that looks for colors of format `#RRGGBBAA` as `#AARRGGBB`.""" +"""Custom color that looks for colors of format `#RRGGBBAA` as `#AARRGGBB`.""" from ..lib.coloraide.spaces.srgb.css import sRGB from ..lib.coloraide.css import parse, serialize import re @@ -10,8 +10,7 @@ class HexSRGB(sRGB): """SRGB that looks for alpha first in hex format.""" - @classmethod - def match(cls, string, start=0, fullmatch=True): + def match(self, string, start=0, fullmatch=True): """Match a CSS color string.""" m = MATCH.match(string, start) @@ -19,9 +18,8 @@ def match(cls, string, start=0, fullmatch=True): return parse.parse_hex(m.group(0).replace('0x', '#', 1)), m.end(0) return None - @classmethod def to_string( - cls, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs + self, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs ): """Convert to CSS.""" diff --git a/custom/st_colormod.py b/custom/st_colormod.py index 7a78b463..d0d2d7fb 100644 --- a/custom/st_colormod.py +++ b/custom/st_colormod.py @@ -184,33 +184,35 @@ def handle_vars(string, variables, parents=None): class HWB(HWBORIG): """HWB class that allows commas.""" - @classmethod - def match(cls, string, start=0, fullmatch=True): + def match(self, string, start=0, fullmatch=True): """Match a CSS color string.""" m = HWB_MATCH.match(string, start) if m is not None and (not fullmatch or m.end(0) == len(string)): return parse.parse_channels( list(RE_CHAN_VALUE.findall(string[m.end(1) + 1:m.end(0) - 1])), - cls.CHANNELS, scaled=True + self.CHANNELS, scaled=True ), m.end(0) return None - @classmethod def to_string( - cls, + self, parent, *, alpha=None, precision=None, - percent: bool = True, + percent=None, fit=True, none=False, + color: bool = False, comma: bool = False, **kwargs ) -> str: """Convert to CSS.""" + if percent is None: + percent = False if color else True + return serialize.serialize_css( parent, func='hwb', diff --git a/custom/tmtheme.py b/custom/tmtheme.py index 68c97105..0f5ffcb4 100644 --- a/custom/tmtheme.py +++ b/custom/tmtheme.py @@ -695,9 +695,8 @@ def name2hex(name): class SRGBX11(sRGB): """sRGB class.""" - @classmethod def to_string( - cls, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs + self, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs ): """Convert to CSS.""" @@ -727,8 +726,7 @@ def to_string( return value - @classmethod - def match(cls, string, start=0, fullmatch=True): + def match(self, string, start=0, fullmatch=True): """Match a CSS color string.""" m = MATCH.match(string, start) diff --git a/docs/src/markdown/settings/previews.md b/docs/src/markdown/settings/previews.md index d921233a..fff8d1ac 100644 --- a/docs/src/markdown/settings/previews.md +++ b/docs/src/markdown/settings/previews.md @@ -88,7 +88,7 @@ else in the code. ```js // Gamut mapping approach - // Supported methods are: `lch-chroma`, `oklch-chroma`, and `clip` (default). + // Supported methods are: `lch-chroma`, `oklch-chroma`, `oklch-raytrace`, and `clip` (default). // `lch-chroma` was the original default before this was configurable. "gamut_map": "clip", ``` diff --git a/lib/coloraide/__meta__.py b/lib/coloraide/__meta__.py index 25fa2bb8..181307fb 100644 --- a/lib/coloraide/__meta__.py +++ b/lib/coloraide/__meta__.py @@ -1,4 +1,5 @@ """Meta related things.""" +from __future__ import annotations from collections import namedtuple import re @@ -83,7 +84,7 @@ def __new__( cls, major: int, minor: int, micro: int, release: str = "final", pre: int = 0, post: int = 0, dev: int = 0 - ) -> "Version": + ) -> Version: """Validate version info.""" # Ensure all parts are positive integers. @@ -192,5 +193,5 @@ def parse_version(ver: str) -> Version: return Version(major, minor, micro, release, pre, post, dev) -__version_info__ = Version(2, 9, 1, "final", post=1) +__version_info__ = Version(3, 3, 1, "final") __version__ = __version_info__._get_canonical() diff --git a/lib/coloraide/algebra.py b/lib/coloraide/algebra.py index f94f23ed..38eb7bf8 100644 --- a/lib/coloraide/algebra.py +++ b/lib/coloraide/algebra.py @@ -22,40 +22,26 @@ used as long as the final results are converted to normal types. It is certainly possible that we could switch to using `numpy` in a major release in the future. """ -import sys +from __future__ import annotations import math import operator import functools import itertools as it from .deprecate import deprecated from .types import ( - ArrayLike, MatrixLike, VectorLike, Array, Matrix, - Vector, Shape, ShapeLike, DimHints, SupportsFloatOrInt, MathType + ArrayLike, MatrixLike, VectorLike, TensorLike, Array, Matrix, Tensor, Vector, VectorBool, MatrixBool, TensorBool, + MatrixInt, MathType, Shape, ShapeLike, DimHints, SupportsFloatOrInt ) -from typing import Optional, Callable, Sequence, List, Union, Iterator, Tuple, Any, Iterable, overload, Dict +from typing import Callable, Sequence, Iterator, Any, Iterable, overload -NaN = float('nan') -INF = float('inf') -nan = NaN -inf = INF -tau = 2 * math.pi +NaN = math.nan +INF = math.inf -PY38 = (3, 8) <= sys.version_info +# Keeping for backwards compatibility +prod = math.prod _all = all _any = any -if sys.version_info >= (3, 8): - # Keeping for backwards compatibility - prod = math.prod -else: - def prod(values: Iterable[SupportsFloatOrInt]) -> SupportsFloatOrInt: - """Get the product of a list of numbers.""" - - if not values: - return 1 - - return functools.reduce(operator.mul, values) - # Shortcut for math operations # Specify one of these in divide, multiply, dot, etc. # to bypass analyzing the shape to determine which path @@ -77,7 +63,7 @@ def prod(values: Iterable[SupportsFloatOrInt]) -> SupportsFloatOrInt: D1_D2 = (1, 2) D2_SC = (2, 0) D2_D1 = (2, 1) -DN_DM = (3, 3) +DN_DM = None # Vector used to create a special matrix used in natural splines M141 = [1, 4, 1] @@ -86,30 +72,12 @@ def prod(values: Iterable[SupportsFloatOrInt]) -> SupportsFloatOrInt: ################################ # General math ################################ -def _math_isclose(a, b, rel_tol=1e-9, abs_tol=0.0): - """Test if values are close.""" - - return abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol) - -@deprecated("Please use math.isnan or alg.isnan for a generic approach for vectors and matrices") -def is_nan(obj: float) -> bool: - """Check if "not a number".""" - - return math.isnan(obj) - - -@deprecated("This will be removed at a future time") -def no_nans(value: Union[VectorLike, Iterable[float]], default: float = 0.0) -> Vector: - """Ensure there are no `NaN` values in a sequence.""" - - return [(default if is_nan(x) else x) for x in value] +def order(x: float) -> int: + """Get the order of magnitude of a number.""" - -@deprecated("This will be removed at a future time") -def no_nan(value: float, default: float = 0.0) -> float: - """Convert list of numbers or single number to valid numbers.""" - - return default if is_nan(value) else value + if x == 0: + return 0 + return math.floor(math.log10(abs(x))) def round_half_up(n: float, scale: int = 0) -> float: @@ -119,32 +87,54 @@ def round_half_up(n: float, scale: int = 0) -> float: return math.floor(n * mult + 0.5) / mult -def round_to(f: float, p: int = 0) -> float: +def round_to(f: float, p: int = 0, half_up: bool = True) -> float: """Round to the specified precision using "half up" rounding.""" + _round = round_half_up if half_up else round # type: Callable[..., float] # type: ignore[assignment] + # Do no rounding, just return a float with full precision if p == -1: return float(f) # Integer rounding - elif p == 0: - return round_half_up(f) + if p == 0: + return _round(f) # Ignore infinity - elif math.isinf(f): + if math.isinf(f): return f # Round to the specified precision else: whole = int(f) digits = 0 if whole == 0 else int(math.log10(-whole if whole < 0 else whole)) + 1 - return round_half_up(whole if digits > p else f, p - digits) + return _round(whole if digits > p else f, p - digits) + + +def minmax(value: VectorLike | Iterable[float]) -> tuple[float, float]: + """Return the minimum and maximum value.""" + + mn = INF + mx = -INF + e = -1 + + for i in value: + e += 1 + if i > mx: + mx = i + if i < mn: + mn = i + + if e == -1: + raise ValueError("minmax() arg is an empty sequence") + + return mn, mx def clamp( value: SupportsFloatOrInt, - mn: Optional[SupportsFloatOrInt] = None, - mx: Optional[SupportsFloatOrInt] = None + mn: SupportsFloatOrInt | None = None, + mx: SupportsFloatOrInt | None = None ) -> SupportsFloatOrInt: """Clamp the value to the the given minimum and maximum.""" @@ -158,6 +148,14 @@ def clamp( return value +def zdiv(a: float, b: float) -> float: + """Protect against zero divide.""" + + if b == 0: + return 0.0 + return a / b + + def cbrt(n: float) -> float: """Calculate cube root.""" @@ -168,7 +166,7 @@ def nth_root(n: float, p: float) -> float: """Calculate nth root while handling negative numbers.""" if p == 0: # pragma: no cover - return inf + return math.inf if n == 0: # Can't do anything with zero @@ -177,13 +175,20 @@ def nth_root(n: float, p: float) -> float: return math.copysign(abs(n) ** (p ** -1), n) -def npow(base: float, exp: float) -> float: - """Perform `pow` with a negative number.""" +def spow(base: float, exp: float) -> float: + """Perform `pow` with signed number.""" return math.copysign(abs(base) ** exp, base) -def rect_to_polar(a: float, b: float) -> Tuple[float, float]: +@deprecated("'npow' has been renamed to 'spow' (signed power), please migrate to avoid future issues.") +def npow(base: float, exp: float) -> float: # pragma: no cover + """Signed power.""" + + return spow(base, exp) + + +def rect_to_polar(a: float, b: float) -> tuple[float, float]: """Take rectangular coordinates and make them polar.""" c = math.sqrt(a ** 2 + b ** 2) @@ -191,7 +196,7 @@ def rect_to_polar(a: float, b: float) -> Tuple[float, float]: return c, h -def polar_to_rect(c: float, h: float) -> Tuple[float, float]: +def polar_to_rect(c: float, h: float) -> tuple[float, float]: """Take rectangular coordinates and make them polar.""" a = c * math.cos(math.radians(h)) @@ -228,14 +233,14 @@ def lerp2d(vertices: Matrix, t: Vector) -> Vector: Vertices should be in column form [[x...], [y...]]. """ - return [bilerp(*(vertices[i] + t)) for i in range(2)] + return [bilerp(*vertices[i], *t) for i in range(2)] def ilerp2d( vertices: Matrix, point: Vector, *, - vertices_t: Optional[Matrix] = None, + vertices_t: Matrix | None = None, max_iter: int = 20, tol: float = 1e-14 ) -> Vector: @@ -319,14 +324,14 @@ def lerp3d( Vertices should be in column form [[x...], [y...], [z...]]. """ - return [trilerp(*(vertices[i] + t)) for i in range(3)] + return [trilerp(*vertices[i], *t) for i in range(3)] def ilerp3d( vertices: Matrix, point: Vector, *, - vertices_t: Optional[Matrix] = None, + vertices_t: Matrix | None = None, max_iter: int = 20, tol: float = 1e-14 ) -> Vector: @@ -445,7 +450,7 @@ def _matrix_141(n: int) -> Matrix: return inv(m) -def naturalize_bspline_controls(coordinates: List[Vector]) -> None: +def naturalize_bspline_controls(coordinates: list[Vector]) -> None: """ Given a set of B-spline control points in the Nth dimension, create new naturalized interpolation control points. @@ -555,12 +560,12 @@ def monotone(p0: float, p1: float, p2: float, p3: float, t: float) -> float: m2 = (s1 + s2) * 0.5 # Center segment should be horizontal as there is no increase/decrease between the two points - if _math_isclose(p1, p2): + if math.isclose(p1, p2): m1 = m2 = 0.0 else: # Gradient is zero if segment is horizontal or if the left hand secant differs in sign from current. - if _math_isclose(p0, p1) or (math.copysign(1.0, s0) != math.copysign(1.0, s1)): + if math.isclose(p0, p1) or (math.copysign(1.0, s0) != math.copysign(1.0, s1)): m1 = 0.0 # Ensure gradient magnitude is either 3 times the left or current secant (smaller being preferred). @@ -568,7 +573,7 @@ def monotone(p0: float, p1: float, p2: float, p3: float, t: float) -> float: m1 *= min(3.0 * s0 / m1, min(3.0 * s1 / m1, 1.0)) # Gradient is zero if segment is horizontal or if the right hand secant differs in sign from current. - if _math_isclose(p2, p3) or (math.copysign(1.0, s1) != math.copysign(1.0, s2)): + if math.isclose(p2, p3) or (math.copysign(1.0, s1) != math.copysign(1.0, s2)): m2 = 0.0 # Ensure gradient magnitude is either 3 times the current or right secant (smaller being preferred). @@ -596,7 +601,7 @@ def monotone(p0: float, p1: float, p2: float, p3: float, t: float) -> float: 'catrom': catrom, 'monotone': monotone, 'linear': lerp -} # type: Dict[str, Callable[..., float]] +} # type: dict[str, Callable[..., float]] class Interpolate: @@ -617,7 +622,7 @@ def __init__( self.callback = callback self.linear = linear - def steps(self, count: int) -> List[Vector]: + def steps(self, count: int) -> list[Vector]: """Generate steps.""" divisor = count - 1 @@ -653,7 +658,7 @@ def __call__(self, t: float) -> Vector: return coord -def interpolate(points: List[Vector], method: str = 'linear') -> Interpolate: +def interpolate(points: list[Vector], method: str = 'linear') -> Interpolate: """Generic interpolation method.""" points = points[:] @@ -680,7 +685,7 @@ def interpolate(points: List[Vector], method: str = 'linear') -> Interpolate: ################################ # Matrix/linear algebra math ################################ -def pretty(value: Union[Array, float], *, _depth: int = 0, _shape: Optional[Shape] = None) -> str: +def pretty(value: float | ArrayLike, *, _depth: int = 0, _shape: Shape | None = None) -> str: """Format the print output.""" if _shape is None: @@ -696,19 +701,19 @@ def pretty(value: Union[Array, float], *, _depth: int = 0, _shape: Optional[Shap return str(value) -def pprint(value: Union[Array, float]) -> None: +def pprint(value: float | ArrayLike) -> None: """Print the matrix or value.""" print(pretty(value)) -def all(a: Union[float, ArrayLike]) -> bool: # noqa: A001 +def all(a: float | ArrayLike) -> bool: # noqa: A001 """Return true if all elements are "true".""" return _all(flatiter(a)) -def any(a: Union[float, ArrayLike]) -> bool: # noqa: A001 +def any(a: float | ArrayLike) -> bool: # noqa: A001 """Return true if all elements are "true".""" return _any(flatiter(a)) @@ -717,28 +722,33 @@ def any(a: Union[float, ArrayLike]) -> bool: # noqa: A001 def vdot(a: VectorLike, b: VectorLike) -> float: """Dot two vectors.""" + l = len(a) + if l != len(b): + raise ValueError('Vectors of size {} and {} are not aligned'.format(l, len(b))) s = 0.0 - for x, y in it.zip_longest(a, b): - s += x * y + i = 0 + while i < l: + s += a[i] * b[i] + i += 1 return s -def vcross(v1: VectorLike, v2: VectorLike) -> Vector: # pragma: no cover +def vcross(v1: VectorLike, v2: VectorLike) -> Any: # pragma: no cover """ Cross two vectors. Takes vectors of either 2 or 3 dimensions. If 2 dimensions, will return the z component. To mix 2 and 3 vector components, please use `cross` instead which will pad 2 dimension - vectors if the other is of 3 dimensions. `cross` has more overhead, so use `cross` if + vectors if the other is of 3 dimensions. `cross` has more overhead, so use `vcross` if you don't need broadcasting of any kind. """ l1 = len(v1) if l1 != len(v2): - raise ValueError('Incompatible dimensions for cross product,') + raise ValueError('Incompatible dimensions of {} and {} for cross product'.format(l1, len(v2))) if l1 == 2: - return [v1[0] * v2[1] - v1[1] * v2[0]] + return v1[0] * v2[1] - v1[1] * v2[0] elif l1 == 3: return [ v1[1] * v2[2] - v1[2] * v2[1], @@ -759,6 +769,11 @@ def acopy(a: MatrixLike) -> Matrix: ... +@overload +def acopy(a: TensorLike) -> Tensor: + ... + + def acopy(a: ArrayLike) -> Array: """Array copy.""" @@ -775,6 +790,11 @@ def _cross_pad(a: MatrixLike, s: Shape) -> Matrix: ... +@overload +def _cross_pad(a: TensorLike, s: Shape) -> Tensor: + ... + + def _cross_pad(a: ArrayLike, s: Shape) -> Array: """Pad an array with 2-D vectors.""" @@ -802,22 +822,7 @@ def _cross_pad(a: ArrayLike, s: Shape) -> Array: return m -@overload -def cross(a: VectorLike, b: VectorLike) -> Vector: - ... - - -@overload -def cross(a: MatrixLike, b: Any) -> Matrix: - ... - - -@overload -def cross(a: Any, b: MatrixLike) -> Matrix: - ... - - -def cross(a: ArrayLike, b: ArrayLike) -> Array: +def cross(a: ArrayLike, b: ArrayLike) -> Any: """Vector cross product.""" # Determine shape of arrays @@ -839,29 +844,35 @@ def cross(a: ArrayLike, b: ArrayLike) -> Array: b = _cross_pad(b, shape_b) shape_b = shape_b[:-1] + (3,) - if dims_a == 1: - if dims_b == 1: - # Cross two vectors - return vcross(a, b) # type: ignore[arg-type] + # Cross two vectors + if dims_a == 1 and dims_b == 1: + return vcross(a, b) # type: ignore[arg-type] + + # Calculate cases of vector crossed either 2-D or N-D matrix and vice versa + if dims_a == 1 or dims_b == 1: + # Calculate target shape + mdim = max(dims_a, dims_b) + new_shape = list(_broadcast_shape([shape_a, shape_b], mdim)) + if mdim > 1 and new_shape[-1] == 2: + new_shape.pop(-1) + + if dims_a == 2: + # Cross a 2-D matrix and a vector + result = [vcross(r, b) for r in a] # type: ignore[arg-type] + elif dims_b == 2: # Cross a vector and a 2-D matrix - return [vcross(a, r) for r in b] # type: ignore[arg-type] + result = [vcross(a, r) for r in b] # type: ignore[arg-type] + + elif dims_a > 2: + # Cross an N-D matrix and a vector + result = [vcross(r, b) for r in _extract_rows(a, shape_a)] # type: ignore[arg-type] + else: # Cross a vector and an N-D matrix - return reshape( # type: ignore[return-value] - [vcross(a, r) for r in _extract_rows(b, shape_b)], # type: ignore[arg-type] - shape_b - ) - elif dims_a == 2: - if dims_b == 1: - # Cross a 2-D matrix and a vector - return [vcross(r, b) for r in a] # type: ignore[arg-type] - elif dims_b == 1: - # Cross an N-D matrix and a vector - return reshape( # type: ignore[return-value] - [vcross(r, b) for r in _extract_rows(a, shape_a)], # type: ignore[arg-type] - shape_a - ) + result = [vcross(a, r) for r in _extract_rows(b, shape_b)] # type: ignore[arg-type] + + return reshape(result, new_shape) # Cross an N-D and M-D matrix bcast = broadcast(a, b) @@ -879,7 +890,14 @@ def cross(a: ArrayLike, b: ArrayLike) -> Array: b2 = [] count = 0 count += 1 - return reshape(data, bcast.shape) # type: ignore[return-value] + + # Adjust shape for the way cross outputs data + new_shape = list(bcast.shape) + mdim = max(dims_a, dims_b) + if mdim > 1 and new_shape[-1] == 2: + new_shape.pop(-1) + + return reshape(data, new_shape) def _extract_rows(m: ArrayLike, s: ShapeLike, depth: int = 0) -> Iterator[Vector]: @@ -901,70 +919,103 @@ def _extract_cols(m: ArrayLike, s: ShapeLike, depth: int = 0) -> Iterator[Vector elif not depth: yield m # type: ignore[misc] else: - yield from [[x[r] for x in m] for r in range(len(m[0]))] # type: ignore[arg-type, index] + yield from [[x[r] for x in m] for r in range(len(m[0]))] # type: ignore[arg-type, index, misc] + + +@overload +def dot(a: float, b: float, *, dims: DimHints | None = ...) -> float: + ... + + +@overload +def dot(a: float, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: + ... @overload -def dot(a: float, b: float, *, dims: Optional[DimHints] = None) -> float: +def dot(a: VectorLike, b: float, *, dims: DimHints | None = ...) -> Vector: ... @overload -def dot(a: float, b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def dot(a: float, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def dot(a: VectorLike, b: float, *, dims: Optional[DimHints] = None) -> Vector: +def dot(a: MatrixLike, b: float, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def dot(a: float, b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def dot(a: float, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: ... @overload -def dot(a: MatrixLike, b: float, *, dims: Optional[DimHints] = None) -> Matrix: +def dot(a: TensorLike, b: float, *, dims: DimHints | None = ...) -> Tensor: ... @overload -def dot(a: VectorLike, b: VectorLike, *, dims: Optional[DimHints] = None) -> float: +def dot(a: VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> float: ... @overload -def dot(a: VectorLike, b: MatrixLike, *, dims: Optional[DimHints] = None) -> Vector: +def dot(a: VectorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def dot(a: MatrixLike, b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def dot(a: MatrixLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def dot(a: MatrixLike, b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def dot(a: VectorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def dot(a: TensorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def dot(a: MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def dot(a: MatrixLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: + ... + + +@overload +def dot(a: TensorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: + ... + + +@overload +def dot(a: TensorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: ... def dot( - a: Union[float, ArrayLike], - b: Union[float, ArrayLike], + a: float | ArrayLike, + b: float | ArrayLike, *, - dims: Optional[DimHints] = None -) -> Union[float, Array]: + dims: DimHints | None = None, +) -> float | Array: """ - Get dot product of simple numbers, vectors, and matrices. - - Matrices will be detected and the appropriate logic applied - unless `dims` is provided. `dims` should simply describe the - number of dimensions of `a` and `b`: (2, 1) for a 2D and 1D array. - Providing `dims` will sidestep analyzing the matrix for a more - performant operation. Anything dimensions above 2 will be treated - as an ND x MD scenario and the actual dimensions will be extracted - regardless due to necessity. + Perform dot product. + + Operations involving scalars will be the same as calling `multiply`. + + If you are doing matrix multiplication, equivalent to `@` in `numpy`, + then you want to use `matmul` instead. Operations on arrays of dimension 2 + or less will act the same as `matmul`. """ if dims is None or dims[0] > 2 or dims[1] > 2: @@ -974,52 +1025,162 @@ def dot( dims_b = len(shape_b) # Handle matrices of N-D and M-D size - if dims_a and dims_b and dims_a > 2 or dims_b > 2: + if dims_a and dims_b and (dims_a > 2 or dims_b > 2): if dims_a == 1: # Dot product of vector and a M-D matrix shape_c = shape_b[:-2] + shape_b[-1:] return reshape([vdot(a, col) for col in _extract_cols(b, shape_b)], shape_c) # type: ignore[arg-type] + elif dims_b == 1: + # Dot product of vector and a M-D matrix + shape_c = shape_a[:-1] + return reshape([vdot(row, b) for row in _extract_rows(a, shape_a)], shape_c) # type: ignore[arg-type] else: # Dot product of N-D and M-D matrices # Resultant size: `dot(xy, yz) = xz` or `dot(nxy, myz) = nxmz` - rows = list(_extract_rows(a, shape_a)) # type: ignore[arg-type] + cols = list(_extract_cols(b, shape_b)) # type: ignore[arg-type] + return reshape( + [ + [sum(multiply(row, col)) for col in cols] + for row in _extract_rows(a, shape_a) # type: ignore[arg-type] + ], + shape_a[:-1] + shape_b[:-2] + shape_b[-1:] + ) + else: + dims_a, dims_b = dims + + # Operations with scalars are the same as simply multiplying + if not dims_a or not dims_b: + return multiply(a, b, dims=(dims_a, dims_b)) + + # Dot is identical to matrix multiply when dimensions are less than or equal to 2, + return matmul(a, b, dims=(dims_a, dims_b)) # type: ignore[arg-type] + + +@overload +def matmul(a: VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> float: + ... + + +@overload +def matmul(a: VectorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Vector: + ... + + +@overload +def matmul(a: MatrixLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: + ... + + +@overload +def matmul(a: VectorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def matmul(a: TensorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def matmul(a: MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def matmul(a: MatrixLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: + ... + + +@overload +def matmul(a: TensorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: + ... + + +@overload +def matmul(a: TensorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: + ... + + +def matmul( + a: ArrayLike, + b: ArrayLike, + *, + dims: DimHints | None = None, +) -> float | Array: + """ + Perform matrix multiplication of two arrays. + + Similar behavior as dot product, but this is limited to non-scalar values only. Additionally, + the behavior of dimensions greater than 2 will be different. Stacks of matrices are broadcast + together as if the matrices were elements, respecting the signature `(n,k),(k,m)->(n,m)`. + This follows `numpy` behavior and is equivalent to the `@` operation. + """ + + if dims is None or dims[0] > 2 or dims[1] > 2: + shape_a = shape(a) + shape_b = shape(b) + dims_a = len(shape_a) + dims_b = len(shape_b) + + # Handle matrices of N-D and M-D size + if dims_a and dims_b and (dims_a > 2 or dims_b > 2): + if dims_a == 1: + # Matrix multiply of vector and a M-D matrix + shape_c = shape_b[:-2] + shape_b[-1:] + return reshape([vdot(a, col) for col in _extract_cols(b, shape_b)], shape_c) # type: ignore[arg-type] + elif dims_b == 1: + # Matrix multiply of vector and a M-D matrix + shape_c = shape_a[:-1] + return reshape([vdot(row, b) for row in _extract_rows(a, shape_a)], shape_c) # type: ignore[arg-type] + elif shape_a[-1] == shape_b[-2]: + # Stacks of matrices are broadcast together as if the matrices were elements, + # respecting the signature `(n,k),(k,m)->(n,m)`. + common = _broadcast_shape([shape_a[:-2], shape_b[:-2]], max(dims_a, dims_b) - 2) + shape_a = common + shape_a[-2:] + a = broadcast_to(a, shape_a) + shape_b = common + shape_b[-2:] + b = broadcast_to(b, shape_b) m2 = [ - [sum(multiply(row, col)) for col in _extract_cols(b, shape_b)] # type: ignore[arg-type] - for row in rows + matmul(a1, b1, dims=D2) + for a1, b1 in zip(_extract_rows(a, shape_a[:-1]), _extract_rows(b, shape_b[:-1])) ] - shape_c = shape_a[:-1] - if dims_b != 1: - shape_c += shape_b[:-2] + shape_b[-1:] - return reshape(m2, shape_c) + return reshape(m2, common + (shape_a[-2], shape_b[-1])) + raise ValueError( + 'Incompatible shapes in core dimensions (n?,k),(k,m?)->(n?,m?), {} != {}'.format( + shape_a[-1], + shape_b[-2] + ) + ) else: dims_a, dims_b = dims # Optimize to handle arrays <= 2-D if dims_a == 1: if dims_b == 1: - # Dot product of two vectors + # Matrix multiply of two vectors return vdot(a, b) # type: ignore[arg-type] elif dims_b == 2: - # Dot product of vector and a matrix - return [vdot(a, col) for col in it.zip_longest(*b)] # type: ignore[arg-type, misc] + # Matrix multiply of vector and a matrix + return [vdot(a, col) for col in it.zip_longest(*b)] # type: ignore[arg-type] elif dims_a == 2: if dims_b == 1: - # Dot product of matrix and a vector - return [vdot(row, b) for row in a] # type: ignore[arg-type, union-attr] + # Matrix multiply of matrix and a vector + return [vdot(row, b) for row in a] # type: ignore[arg-type] elif dims_b == 2: - # Dot product of two matrices + # Matrix multiply of two matrices + cols = list(it.zip_longest(*b)) return [ - [vdot(row, col) for col in it.zip_longest(*b)] for row in a # type: ignore[arg-type, misc, union-attr] + [vdot(row, col) for col in cols] for row in a # type: ignore[arg-type] ] - # Trying to dot a number with a vector or a matrix, so just multiply - return multiply(a, b, dims=(dims_a, dims_b)) + # Scalars are not allowed + raise ValueError('Inputs require at least 1 dimension, scalars are not allowed') -def _matrix_chain_order(shapes: Sequence[Shape]) -> List[List[int]]: +def _matrix_chain_order(shapes: Sequence[Shape]) -> MatrixInt: """ Calculate chain order. @@ -1038,13 +1199,13 @@ def _matrix_chain_order(shapes: Sequence[Shape]) -> List[List[int]]: n = len(shapes) m = full((n, n), 0) # type: Any - s = full((n, n), 0) # type: List[List[int]] # type: ignore[assignment] + s = full((n, n), 0) # type: MatrixInt # type: ignore[assignment] p = [a[0] for a in shapes] + [shapes[-1][1]] for d in range(1, n): for i in range(n - d): j = i + d - m[i][j] = inf + m[i][j] = math.inf for k in range(i, j): cost = m[i][k] + m[k + 1][j] + p[i] * p[k + 1] * p[j + 1] if cost < m[i][j]: @@ -1053,7 +1214,7 @@ def _matrix_chain_order(shapes: Sequence[Shape]) -> List[List[int]]: return s -def _multi_dot(arrays: Sequence[ArrayLike], indexes: List[List[int]], i: int, j: int) -> ArrayLike: +def _multi_dot(arrays: Sequence[ArrayLike], indexes: MatrixInt, i: int, j: int) -> ArrayLike: """Recursively dot the matrices in the array.""" if i != j: @@ -1149,7 +1310,7 @@ class _BroadcastTo: - The new shape. """ - def __init__(self, array: ArrayLike, old: Shape, new: Shape) -> None: + def __init__(self, array: ArrayLike | float, old: Shape, new: Shape) -> None: """Initialize.""" self._loop1 = 0 @@ -1157,7 +1318,7 @@ def __init__(self, array: ArrayLike, old: Shape, new: Shape) -> None: self._chunk_subindex = 0 self._chunk_max = 0 self._chunk_index = 0 - self._chunk = [] # type: List[float] + self._chunk = [] # type: Vector # Unravel the data as it will be quicker to slice the data in a flattened form # than iterating over the dimensions to replicate the data. @@ -1262,7 +1423,7 @@ class _SimpleBroadcast: def __init__( self, - arrays: Sequence[Union[ArrayLike, float]], + arrays: Sequence[ArrayLike | float], shapes: Sequence[Shape], new: ShapeLike ) -> None: @@ -1272,7 +1433,7 @@ def __init__( total = len(arrays) if total == 0: - a, b = None, None # type: Tuple[Any, Any] + a, b = None, None # type: tuple[Any, Any] elif total == 1: a, b = arrays[0], None else: @@ -1286,7 +1447,7 @@ def __init__( self.reset() - def vector_broadcast(self, a: VectorLike, b: VectorLike) -> Iterator[Tuple[float, ...]]: + def vector_broadcast(self, a: VectorLike, b: VectorLike) -> Iterator[tuple[float, ...]]: """Broadcast two vectors.""" # Broadcast the vector @@ -1299,10 +1460,10 @@ def vector_broadcast(self, a: VectorLike, b: VectorLike) -> Iterator[Tuple[float def broadcast( self, - a: Optional[Union[ArrayLike, float]], - b: Optional[Union[ArrayLike, float]], + a: ArrayLike | float | None, + b: ArrayLike | float | None, dims_a: int, dims_b: int - ) -> Iterator[Tuple[float, ...]]: + ) -> Iterator[tuple[float, ...]]: """Simple broadcast of a single array or two arrays with dimensions less than 2.""" # One of the common dimensions makes this result empty @@ -1322,8 +1483,19 @@ def broadcast( yield from self.vector_broadcast(a, b) # type: ignore[arg-type] elif dims_a == 2: # Broadcast two 2-D matrices - for ra, rb in it.zip_longest(a, b): # type: ignore[arg-type] - yield from self.vector_broadcast(ra, rb) # type: ignore[arg-type] + la = len(a) # type: ignore[arg-type] + lb = len(b) # type: ignore[arg-type] + if la == 1 and lb != 1: + ra = a[0] # type: ignore[index] + for rb in b: # type: ignore[union-attr] + yield from self.vector_broadcast(ra, rb) # type: ignore[arg-type] + elif lb == 1 and la != 1: + rb = b[0] # type: ignore[index] + for ra in a: # type: ignore[union-attr] + yield from self.vector_broadcast(ra, rb) # type: ignore[arg-type] + else: + for ra, rb in it.zip_longest(a, b): # type: ignore[arg-type] + yield from self.vector_broadcast(ra, rb) # type: ignore[arg-type] else: yield a, b # type: ignore[misc] @@ -1362,23 +1534,47 @@ def reset(self) -> None: self._iter = self.broadcast(self.a, self.b, self.dims_a, self.dims_b) - def __next__(self) -> Tuple[float, ...]: + def __next__(self) -> tuple[float, ...]: """Next.""" # Get the next chunk of data return next(self._iter) - def __iter__(self) -> Iterator[Tuple[float, ...]]: # pragma: no cover + def __iter__(self) -> Iterator[tuple[float, ...]]: # pragma: no cover """Iterate.""" # Setup and and return the iterator. return self +def _broadcast_shape(shapes: list[Shape], max_dims: int, stage1_shapes: list[Shape] | None = None) -> Shape: + """Find the common shape.""" + + # Adjust array shapes by padding out with '1's until matches max dimensions + if stage1_shapes is None: + stage1_shapes = [] + + for s in shapes: + dims = len(s) + stage1_shapes.append(((1,) * (max_dims - dims)) + s if dims < max_dims else s) + + # Determine a common shape, if possible + s2 = [] + for dim in zip(*stage1_shapes): + mx = 1 + for d in dim: + if d != 1 and (d != mx and mx != 1): + raise ValueError("Could not broadcast arrays as shapes are incompatible") + if d != 1: + mx = d + s2.append(mx) + return tuple(s2) + + class Broadcast: """Broadcast.""" - def __init__(self, *arrays: Union[ArrayLike, float]) -> None: + def __init__(self, *arrays: ArrayLike | float) -> None: """Broadcast.""" # Determine maximum dimensions @@ -1391,29 +1587,14 @@ def __init__(self, *arrays: Union[ArrayLike, float]) -> None: max_dims = dims shapes.append(s) - # Adjust array shapes by padding out with '1's until matches max dimensions - stage1_shapes = [] - for s in shapes: - dims = len(s) - stage1_shapes.append(((1,) * (max_dims - dims)) + s if dims < max_dims else s) - - # Determine a common shape, if possible - s2 = [] - for dim in zip(*stage1_shapes): - mx = 1 - for d in dim: - if d != 1 and (d != mx and mx != 1): - raise ValueError("Could not broadcast arrays as shapes are incompatible") - if d != 1: - mx = d - s2.append(mx) - common = tuple(s2) + stage1_shapes = [] # type: list[Shape] + common = _broadcast_shape(shapes, max_dims, stage1_shapes) # Create iterators to "broadcast to" total = len(arrays) self.simple = total < 2 or (total == 2 and len(common) <= 2) if self.simple: - self.iters = [_SimpleBroadcast(arrays, shapes, common)] # type: Union[List[_BroadcastTo], List[_SimpleBroadcast]] + self.iters = [_SimpleBroadcast(arrays, shapes, common)] # type: list[_BroadcastTo] | list[_SimpleBroadcast] else: self.iters = [_BroadcastTo(a, s1, common) for a, s1 in zip(arrays, stage1_shapes)] @@ -1437,26 +1618,26 @@ def reset(self) -> None: i.reset() self._init() - def __next__(self) -> Tuple[float, float]: + def __next__(self) -> tuple[float, ...]: """Next.""" # Get the next chunk of data - return next(self._iter) # type: ignore[return-value] + return next(self._iter) # type: ignore[arg-type] - def __iter__(self) -> 'Broadcast': + def __iter__(self) -> Broadcast: """Iterate.""" # Setup and and return the iterator. return self -def broadcast(*arrays: ArrayLike) -> Broadcast: +def broadcast(*arrays: ArrayLike | float) -> Broadcast: """Broadcast.""" return Broadcast(*arrays) -def broadcast_to(a: Union[ArrayLike, float], s: Union[int, ShapeLike]) -> Array: +def broadcast_to(a: ArrayLike | float, s: int | ShapeLike) -> Array: """Broadcast array to a shape.""" if not isinstance(s, Sequence): @@ -1501,8 +1682,8 @@ class vectorize: def __init__( self, pyfunc: Callable[..., Any], - doc: Optional[str] = None, - excluded: Optional[Sequence[Union[str, int]]] = None + doc: str | None = None, + excluded: Sequence[str | int] | None = None ) -> None: """Initialize.""" @@ -1556,24 +1737,72 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: return self.func(*inputs, **kwargs) -class vectorize2: +class vectorize1: """ A special version of vectorize that only broadcasts the first two inputs. This approach is faster than vectorize because it limits the inputs and allows us to skip a lot of the generalized code that can slow the things down. Additionally, we allow a `dims` keyword that allows you to specify the dimensions of the inputs - that can fast track a decision on how to process in the inputs. + that can fast track a decision on how to process in the inputs. The positional + argument is always vectorized and are expected to be numbers. + + For more flexibility, use `vectorize` which allows arbitrary vectorization of any and + all inputs at the cost of speed. + """ - If desired, a function that takes either one or two positional arguments is allowed, - no more no less. The second positional argument can be optional. The positional + def __init__(self, pyfunc: Callable[..., Any], doc: str | None = None): + """Initialize.""" + + self.func = pyfunc + + # Setup function name and docstring + self.__name__ = self.func.__name__ + self.__doc__ = self.func.__doc__ if doc is None else doc + + def __call__( + self, + a: ArrayLike | float, + dims: DimHints | None = None, + **kwargs: Any + ) -> Any: + """Call the vectorized function.""" + + if dims and 0 <= dims[0] <= 2: + dims_a = dims[0] + else: + dims_a = len(shape(a)) + + # Fast paths for scalar, vectors, and 2D matrices + # Scalar + if dims_a == 0: + return self.func(a, **kwargs) + # Vector + elif dims_a == 1: + return [self.func(i, **kwargs) for i in a] # type: ignore[union-attr] + # 2D matrix + elif dims_a == 2: + return [[self.func(c, **kwargs) for c in r] for r in a] # type: ignore[union-attr] + + # Unknown size or larger than 2D (slow) + return reshape([self.func(f, **kwargs) for f in flatiter(a)], shape(a)) + + +class vectorize2: + """ + A special version of vectorize that only broadcasts the first two inputs. + + This approach is faster than vectorize because it limits the inputs and allows us + to skip a lot of the generalized code that can slow the things down. Additionally, + we allow a `dims` keyword that allows you to specify the dimensions of the inputs + that can fast track a decision on how to process in the inputs. The positional arguments are always vectorized and are expected to be numbers. For more flexibility, use `vectorize` which allows arbitrary vectorization of any and all inputs at the cost of speed. """ - def __init__(self, pyfunc: Callable[..., Any], doc: Optional[str] = None): + def __init__(self, pyfunc: Callable[..., Any], doc: str | None = None): """Initialize.""" self.func = pyfunc @@ -1595,38 +1824,13 @@ def _vector_apply(self, a: VectorLike, b: VectorLike, **kwargs: Any) -> Vector: def __call__( self, - *args: Union[ArrayLike, float], - dims: Optional[DimHints] = None, + a: ArrayLike | float, + b: ArrayLike | float, + dims: DimHints | None = None, **kwargs: Any ) -> Any: """Call the vectorized function.""" - if len(args) == 1: - a, = args - if dims and 0 <= dims[0] <= 2: - dims_a = dims[0] - # Shape doesn't matter as we will utilize a fast path - shape_a = (0,) # type: Shape - else: - shape_a = shape(a) - dims_a = len(shape(a)) - - # Fast paths for scalar, vectors, and 2D matrices - # Scalar - if dims_a == 0: - return self.func(a, **kwargs) - # Vector - elif dims_a == 1: - return [self.func(i, **kwargs) for i in a] # type: ignore[union-attr] - # 2D matrix - elif dims_a == 2: - return [[self.func(c, **kwargs) for c in r] for r in a] # type: ignore[union-attr] - - # Unknown size or larger than 2D (slow) - return reshape([self.func(f, **kwargs) for f in flatiter(a)], shape_a) - - a, b = args - if not dims or dims[0] > 2 or dims[1] > 2: shape_a = shape(a) shape_b = shape(b) @@ -1661,9 +1865,18 @@ def __call__( return self._vector_apply(a, b, **kwargs) # type: ignore[arg-type] elif dims_a == 2: # Apply math to two 2-D matrices + la = len(a) # type: ignore[arg-type] + lb = len(b) # type: ignore[arg-type] + if la == 1 and lb != 1: + ra = a[0] # type: ignore[index] + return [self._vector_apply(ra, rb) for rb in b] # type: ignore[arg-type, union-attr] + elif lb == 1 and la != 1: + rb = b[0] # type: ignore[index] + return [self._vector_apply(ra, rb) for ra in a] # type: ignore[arg-type, union-attr] return [ self._vector_apply(ra, rb, **kwargs) for ra, rb in it.zip_longest(a, b) # type: ignore[arg-type] ] + # Apply math to two scalars return self.func(a, b, **kwargs) # Inputs containing a scalar on either side @@ -1694,26 +1907,26 @@ def linspace(start: float, stop: float) -> Vector: @overload -def linspace(start: VectorLike, stop: Union[VectorLike, float]) -> Matrix: +def linspace(start: VectorLike, stop: VectorLike | float) -> Matrix: ... @overload -def linspace(start: Union[VectorLike, float], stop: VectorLike) -> Matrix: +def linspace(start: VectorLike | float, stop: VectorLike) -> Matrix: ... @overload -def linspace(start: MatrixLike, stop: ArrayLike) -> Matrix: +def linspace(start: MatrixLike, stop: ArrayLike) -> Tensor: ... @overload -def linspace(start: ArrayLike, stop: MatrixLike) -> Matrix: +def linspace(start: ArrayLike, stop: MatrixLike) -> Tensor: ... -def linspace(start: Union[ArrayLike, float], stop: Union[ArrayLike, float], num: int = 50, endpoint: bool = True) -> Array: +def linspace(start: ArrayLike | float, stop: ArrayLike | float, num: int = 50, endpoint: bool = True) -> Array: """Create a series of points in a linear space.""" if num < 0: @@ -1778,22 +1991,27 @@ def linspace(start: Union[ArrayLike, float], stop: Union[ArrayLike, float], num: def _isclose(a: float, b: float, *, equal_nan: bool = False, **kwargs: Any) -> bool: """Check if values are close.""" - close = _math_isclose(a, b, **kwargs) + close = math.isclose(a, b, **kwargs) return (math.isnan(a) and math.isnan(b)) if not close and equal_nan else close @overload # type: ignore[no-overload-impl] -def isclose(a: float, b: float, *, dims: Optional[DimHints] = None, **kwargs: Any) -> bool: +def isclose(a: float, b: float, *, dims: DimHints | None = ..., **kwargs: Any) -> bool: + ... + + +@overload +def isclose(a: VectorLike, b: VectorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> VectorBool: ... @overload -def isclose(a: VectorLike, b: VectorLike, *, dims: Optional[DimHints] = None, **kwargs: Any) -> List[bool]: +def isclose(a: MatrixLike, b: MatrixLike, *, dims: DimHints | None = ..., **kwargs: Any) -> MatrixBool: ... @overload -def isclose(a: MatrixLike, b: MatrixLike, *, dims: Optional[DimHints] = None, **kwargs: Any) -> List[List[bool]]: +def isclose(a: TensorLike, b: TensorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> TensorBool: ... @@ -1801,21 +2019,26 @@ def isclose(a: MatrixLike, b: MatrixLike, *, dims: Optional[DimHints] = None, ** @overload # type: ignore[no-overload-impl] -def isnan(a: float, *, dims: Optional[DimHints] = None, **kwargs: Any) -> bool: +def isnan(a: float, *, dims: DimHints | None = ..., **kwargs: Any) -> bool: ... @overload -def isnan(a: VectorLike, *, dims: Optional[DimHints] = None, **kwargs: Any) -> List[bool]: +def isnan(a: VectorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> VectorBool: ... @overload -def isnan(a: MatrixLike, *, dims: Optional[DimHints] = None, **kwargs: Any) -> List[List[bool]]: +def isnan(a: MatrixLike, *, dims: DimHints | None = ..., **kwargs: Any) -> MatrixBool: ... -isnan = vectorize2(math.isnan) # type: ignore[assignment] +@overload +def isnan(a: TensorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> TensorBool: + ... + + +isnan = vectorize1(math.isnan) # type: ignore[assignment] def allclose(a: MathType, b: MathType, **kwargs: Any) -> bool: @@ -1825,153 +2048,157 @@ def allclose(a: MathType, b: MathType, **kwargs: Any) -> bool: @overload # type: ignore[no-overload-impl] -def multiply(a: float, b: float, *, dims: Optional[DimHints] = None) -> float: +def multiply(a: float, b: float, *, dims: DimHints | None = ...) -> float: ... @overload -def multiply(a: Union[float, VectorLike], b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def multiply(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def multiply(a: VectorLike, b: Union[float, VectorLike], *, dims: Optional[DimHints] = None) -> Vector: +def multiply(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def multiply(a: MatrixLike, b: Union[float, ArrayLike], *, dims: Optional[DimHints] = None) -> Matrix: +def multiply(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def multiply(a: Union[ArrayLike, float], b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def multiply(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... -multiply = vectorize2(operator.mul, doc="Multiply two arrays or floats.") # type: ignore[assignment] - -@overload # type: ignore[no-overload-impl] -def divide(a: float, b: float, *, dims: Optional[DimHints] = None) -> float: +@overload +def multiply(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: ... @overload -def divide(a: Union[float, VectorLike], b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def multiply(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: ... -@overload -def divide(a: VectorLike, b: Union[float, VectorLike], *, dims: Optional[DimHints] = None) -> Vector: - ... +multiply = vectorize2(operator.mul, doc="Multiply two arrays or floats.") # type: ignore[assignment] -@overload -def divide(a: MatrixLike, b: Union[float, ArrayLike], *, dims: Optional[DimHints] = None) -> Matrix: +@overload # type: ignore[no-overload-impl] +def divide(a: float, b: float, *, dims: DimHints | None = ...) -> float: ... @overload -def divide(a: Union[ArrayLike, float], b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def divide(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: ... -divide = vectorize2(operator.truediv, doc="Divide two arrays or floats.") # type: ignore[assignment] - - -@overload # type: ignore[no-overload-impl] -def add(a: float, b: float, *, dims: Optional[DimHints] = None) -> float: +@overload +def divide(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def add(a: Union[float, VectorLike], b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def divide(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def add(a: VectorLike, b: Union[float, VectorLike], *, dims: Optional[DimHints] = None) -> Vector: +def divide(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def add(a: MatrixLike, b: Union[float, ArrayLike], *, dims: Optional[DimHints] = None) -> Matrix: +def divide(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: ... @overload -def add(a: Union[ArrayLike, float], b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def divide(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: ... -add = vectorize2(operator.add, doc="Add two arrays or floats.") # type: ignore[assignment] +divide = vectorize2(operator.truediv, doc="Divide two arrays or floats.") # type: ignore[assignment] @overload # type: ignore[no-overload-impl] -def subtract(a: float, b: float, *, dims: Optional[DimHints] = None) -> float: +def add(a: float, b: float, *, dims: DimHints | None = ...) -> float: ... @overload -def subtract(a: Union[float, VectorLike], b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def add(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def subtract(a: VectorLike, b: Union[float, VectorLike], *, dims: Optional[DimHints] = None) -> Vector: +def add(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def subtract(a: MatrixLike, b: Union[float, ArrayLike], *, dims: Optional[DimHints] = None) -> Matrix: +def add(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def subtract(a: Union[ArrayLike, float], b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def add(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... -subtract = vectorize2(operator.sub, doc="Subtract two arrays or floats.") # type: ignore[assignment] +@overload +def add(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: + ... @overload -def apply(fn: Callable[..., float], a: float, b: Optional[float] = None) -> float: +def add(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: + ... + + +add = vectorize2(operator.add, doc="Add two arrays or floats.") # type: ignore[assignment] + + +@overload # type: ignore[no-overload-impl] +def subtract(a: float, b: float, *, dims: DimHints | None = None) -> float: ... @overload -def apply(fn: Callable[..., float], a: Union[float, VectorLike], b: VectorLike) -> Vector: +def subtract(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def apply(fn: Callable[..., float], a: VectorLike, b: Optional[Union[float, VectorLike]] = None) -> Vector: +def subtract(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def apply(fn: Callable[..., float], a: MatrixLike, b: Optional[Union[float, ArrayLike]] = None) -> Matrix: +def subtract(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def apply(fn: Callable[..., float], a: Union[ArrayLike, float], b: MatrixLike) -> Matrix: +def subtract(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... -@deprecated("Please use vectorize2 (comparable in speed and features) or vectorize (more general purpose)") -def apply( - fn: Callable[..., float], - *args: Union[ArrayLike, float], - dims: Optional[DimHints] = None -) -> Union[float, Array]: - """Apply a given function over each element of the matrix.""" +@overload +def subtract(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: + ... - return vectorize2(fn)(*args, dims=dims) # type: ignore[no-any-return] +@overload +def subtract(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: + ... + +subtract = vectorize2(operator.sub, doc="Subtract two arrays or floats.") # type: ignore[assignment] -def full(array_shape: Union[int, ShapeLike], fill_value: Union[float, ArrayLike]) -> Array: + +def full(array_shape: int | ShapeLike, fill_value: float | ArrayLike) -> Array: """Create and fill a shape with the given values.""" # Ensure `shape` is a sequence of sizes @@ -1988,19 +2215,19 @@ def full(array_shape: Union[int, ShapeLike], fill_value: Union[float, ArrayLike] return reshape(fill_value, array_shape) # type: ignore[return-value] -def ones(array_shape: Union[int, ShapeLike]) -> Array: +def ones(array_shape: int | ShapeLike) -> Array: """Create and fill a shape with ones.""" return full(array_shape, 1.0) -def zeros(array_shape: Union[int, ShapeLike]) -> Array: +def zeros(array_shape: int | ShapeLike) -> Array: """Create and fill a shape with zeros.""" return full(array_shape, 0.0) -def ndindex(*s: ShapeLike) -> Iterator[Tuple[int, ...]]: +def ndindex(*s: ShapeLike) -> Iterator[tuple[int, ...]]: """Iterate dimensions.""" yield from it.product( @@ -2008,8 +2235,7 @@ def ndindex(*s: ShapeLike) -> Iterator[Tuple[int, ...]]: ) - -def flatiter(array: Union[float, ArrayLike]) -> Iterator[float]: +def flatiter(array: float | ArrayLike) -> Iterator[float]: """Traverse an array returning values.""" for indices in ndindex(shape(array)): @@ -2019,7 +2245,7 @@ def flatiter(array: Union[float, ArrayLike]) -> Iterator[float]: yield m -def ravel(array: Union[float, ArrayLike]) -> Vector: +def ravel(array: float | ArrayLike) -> Vector: """Return a flattened vector.""" return list(flatiter(array)) @@ -2038,7 +2264,7 @@ def _frange(start: float, stop: float, step: float) -> Iterator[float]: def arange( start: SupportsFloatOrInt, - stop: Optional[SupportsFloatOrInt] = None, + stop: SupportsFloatOrInt | None = None, step: SupportsFloatOrInt = 1 ) -> Vector: """ @@ -2069,11 +2295,16 @@ def transpose(array: VectorLike) -> Vector: @overload -def transpose(array: Matrix) -> Matrix: +def transpose(array: MatrixLike) -> Matrix: + ... + + +@overload +def transpose(array: TensorLike) -> Tensor: ... -def transpose(array: Union[ArrayLike, float]) -> Array: +def transpose(array: ArrayLike | float) -> Array | float: """ A simple transpose of a matrix. @@ -2081,7 +2312,7 @@ def transpose(array: Union[ArrayLike, float]) -> Array: we don't have a need for that, nor the desire to figure it out :). """ - s = tuple(reversed(shape(array))) + s = shape(array)[::-1] if not s: return array # type: ignore[return-value] @@ -2136,7 +2367,7 @@ def transpose(array: Union[ArrayLike, float]) -> Array: return m # type: ignore[no-any-return] -def reshape(array: ArrayLike, new_shape: Union[int, ShapeLike]) -> Union[float, Array]: +def reshape(array: ArrayLike | float, new_shape: int | ShapeLike) -> float | Array: """Change the shape of an array.""" # Ensure floats are arrays @@ -2152,9 +2383,8 @@ def reshape(array: ArrayLike, new_shape: Union[int, ShapeLike]) -> Union[float, v = ravel(array) if len(v) == 1: return v[0] - elif v: - # Kick out if the requested shape doesn't match the data - raise ValueError('Shape {} does not match the data total of {}'.format(new_shape, shape(array))) + # Kick out if the requested shape doesn't match the data + raise ValueError('Shape {} does not match the data total of {}'.format(new_shape, shape(array))) current_shape = shape(array) @@ -2196,7 +2426,7 @@ def reshape(array: ArrayLike, new_shape: Union[int, ShapeLike]) -> Union[float, return m # type: ignore[no-any-return] -def _shape(a: Any, s: Shape) -> Shape: +def _shape(a: ArrayLike | float, s: Shape) -> Shape: """ Get the shape of the array. @@ -2224,13 +2454,13 @@ def _shape(a: Any, s: Shape) -> Shape: return (size,) + first -def shape(a: Union[ArrayLike, float]) -> Shape: +def shape(a: ArrayLike | float) -> Shape: """Get the shape of a list.""" return _shape(a, ()) -def fill_diagonal(matrix: MatrixLike, val: Union[float, ArrayLike], wrap: bool = False) -> None: +def fill_diagonal(matrix: Matrix | Tensor, val: float | ArrayLike, wrap: bool = False) -> None: """Fill an N-D matrix diagonal.""" s = shape(matrix) @@ -2268,7 +2498,7 @@ def fill_diagonal(matrix: MatrixLike, val: Union[float, ArrayLike], wrap: bool = pos = pos + 1 if pos < dlen else 0 -def eye(n: int, m: Optional[int] = None, k: int = 0) -> Matrix: +def eye(n: int, m: int | None = None, k: int = 0) -> Matrix: """Create a diagonal of ones in a zero initialized matrix at the specified position.""" if m is None: @@ -2306,7 +2536,7 @@ def diag(array: MatrixLike, k: int = 0) -> Vector: ... -def diag(array: ArrayLike, k: int = 0) -> Array: +def diag(array: VectorLike | MatrixLike, k: int = 0) -> Array: """Create a diagonal matrix from a vector or return a vector of the diagonal of a matrix.""" s = shape(array) @@ -2343,11 +2573,11 @@ def diag(array: ArrayLike, k: int = 0) -> Array: def lu( - matrix: MatrixLike, + matrix: MatrixLike | TensorLike, *, permute_l: bool = False, p_indices: bool = False, - _shape: Optional[Shape] = None + _shape: Shape | None = None ) -> Any: """ Calculate `LU` decomposition. @@ -2400,12 +2630,12 @@ def lu( size = s[1] wide = True for _ in range(diff): - matrix.append([0.0] * size) # noqa: PERF401 + matrix.append([0.0] * size) # type: ignore[list-item] # noqa: PERF401 # Tall else: tall = True for row in matrix: - row.extend([0.0] * diff) + row.extend([0.0] * diff) # type: ignore[list-item] # Initialize the triangle matrices along with the permutation matrix. if p_indices or permute_l: @@ -2421,9 +2651,9 @@ def lu( # Partial pivoting: identify the row with the maximal value in the column j = i - maximum = abs(u[i][i]) + maximum = abs(u[i][i]) # type: ignore[var-annotated, arg-type] for k in range(i + 1, size): - a = abs(u[k][i]) + a = abs(u[k][i]) # type: ignore[var-annotated, arg-type] if a > maximum: j = k maximum = a @@ -2447,9 +2677,9 @@ def lu( # We have a pivot point, let's zero out everything above and below # the 'l' and 'u' diagonal respectively for j in range(i + 1, size): - scalar = u[j][i] / u[i][i] + scalar = u[j][i] / u[i][i] # type: ignore[operator] for k in range(i, size): - u[j][k] += -u[i][k] * scalar + u[j][k] += -u[i][k] * scalar # type: ignore[operator] l[j][k] += l[i][k] * scalar # Clean up the wide and tall matrices @@ -2533,7 +2763,17 @@ def solve(a: MatrixLike, b: MatrixLike) -> Matrix: ... -def solve(a: MatrixLike, b: ArrayLike) -> Array: +@overload +def solve(a: MatrixLike, b: TensorLike) -> Tensor: + ... + + +@overload +def solve(a: TensorLike, b: MatrixLike | TensorLike) -> Tensor | Matrix: + ... + + +def solve(a: MatrixLike | TensorLike, b: ArrayLike) -> Array: """ Solve the system of equations. @@ -2688,7 +2928,17 @@ def det(matrix: MatrixLike) -> Any: return [det(rows[r:r + step]) for r in range(0, len(rows), step)] +@overload def inv(matrix: MatrixLike) -> Matrix: + ... + + +@overload +def inv(matrix: TensorLike) -> Tensor: + ... + + +def inv(matrix: MatrixLike | TensorLike) -> Matrix | Tensor: """Invert the matrix using `LU` decomposition.""" # Ensure we have a square matrix @@ -2704,7 +2954,7 @@ def inv(matrix: MatrixLike) -> Matrix: rows = list(_extract_rows(matrix, s)) step = last[-2] invert = [inv(rows[r:r + step]) for r in range(0, len(rows), step)] - return reshape(invert, s) # type: ignore [arg-type, return-value] + return reshape(invert, s) # type: ignore[return-value] # Calculate the LU decomposition. size = s[0] @@ -2722,10 +2972,20 @@ def inv(matrix: MatrixLike) -> Matrix: return _back_sub_matrix(u, _forward_sub_matrix(l, p, s2), s2) -def vstack(arrays: Sequence[ArrayLike]) -> Array: +@overload +def vstack(arrays: Sequence[float | Vector | Matrix]) -> Matrix: + ... + + +@overload +def vstack(arrays: Sequence[Tensor]) -> Tensor: + ... + + +def vstack(arrays: Sequence[ArrayLike | float]) -> Matrix | Tensor: """Vertical stack.""" - m = [] # type: List[Array] + m = [] # type: list[Any] dims = 0 # Array tracking for verification @@ -2772,7 +3032,7 @@ def vstack(arrays: Sequence[ArrayLike]) -> Array: return m -def _hstack_extract(a: Union[ArrayLike, float], s: ShapeLike) -> Iterator[Array]: +def _hstack_extract(a: ArrayLike | float, s: ShapeLike) -> Iterator[Array]: """Extract data from the second axis.""" data = flatiter(a) @@ -2782,7 +3042,7 @@ def _hstack_extract(a: Union[ArrayLike, float], s: ShapeLike) -> Iterator[Array] yield [next(data) for _ in range(length)] -def hstack(arrays: Sequence[Union[ArrayLike, float]]) -> Array: +def hstack(arrays: Sequence[ArrayLike | float]) -> Array: """Horizontal stack.""" # Gather up shapes @@ -2850,7 +3110,7 @@ def hstack(arrays: Sequence[Union[ArrayLike, float]]) -> Array: return m1 # Iterate the arrays returning the content per second axis - m = [] # type: List[Any] + m = [] # type: list[Any] for data in it.zip_longest(*[_hstack_extract(a, s) for a, s in it.zip_longest(arrs, shapes) if s != (0,)]): for d in data: m.extend(d) @@ -2860,14 +3120,14 @@ def hstack(arrays: Sequence[Union[ArrayLike, float]]) -> Array: return reshape(m, new_shape) # type: ignore[return-value] -def outer(a: Union[float, ArrayLike], b: Union[float, ArrayLike]) -> Matrix: +def outer(a: float | ArrayLike, b: float | ArrayLike) -> Matrix: """Compute the outer product of two vectors (or flattened matrices).""" v2 = ravel(b) return [[x * y for y in v2] for x in flatiter(a)] -def inner(a: Union[float, ArrayLike], b: Union[float, ArrayLike]) -> Union[float, Array]: +def inner(a: float | ArrayLike, b: float | ArrayLike) -> float | Array: """Compute the inner product of two arrays.""" shape_a = shape(a) @@ -2889,7 +3149,7 @@ def inner(a: Union[float, ArrayLike], b: Union[float, ArrayLike]) -> Union[float if dims_a == 1: first = [a] # type: Any elif dims_a > 2: - first = list(_extract_rows(a, shape_a)) # type: ignore[arg-type] + first = _extract_rows(a, shape_a) # type: ignore[arg-type] else: first = a diff --git a/lib/coloraide/average.py b/lib/coloraide/average.py index 716c55fe..36f0dc68 100644 --- a/lib/coloraide/average.py +++ b/lib/coloraide/average.py @@ -1,27 +1,27 @@ """Average colors together.""" +from __future__ import annotations import math -from . import algebra as alg from .types import ColorInput -from typing import Iterable, TYPE_CHECKING, Type +from typing import Iterable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from .color import Color def average( - create: Type['Color'], + create: type[Color], colors: Iterable[ColorInput], space: str, premultiplied: bool = True, powerless: bool = False -) -> 'Color': +) -> Color: """Average a list of colors together.""" obj = create(space, []) # Get channel information cs = obj.CS_MAP[space] - hue_index = cs.hue_index() if hasattr(cs, 'hue_index') else -1 + hue_index = cs.hue_index() if cs.is_polar() else -1 # type: ignore[attr-defined] channels = cs.channels chan_count = len(channels) alpha_index = chan_count - 1 @@ -36,7 +36,7 @@ def average( obj.update(c) # If cylindrical color is achromatic, ensure hue is undefined if powerless and hue_index >= 0 and not math.isnan(obj[hue_index]) and obj.is_achromatic(): - obj[hue_index] = alg.nan + obj[hue_index] = math.nan coords = obj[:] alpha = coords[-1] if math.isnan(alpha): @@ -59,16 +59,17 @@ def average( # Get the mean alpha = sums[-1] alpha_t = totals[-1] - sums[-1] = alg.nan if not alpha_t else alpha / alpha_t + sums[-1] = math.nan if not alpha_t else alpha / alpha_t alpha = sums[-1] if math.isnan(alpha) or alpha in (0.0, 1.0): alpha = 1.0 for i in range(chan_count - 1): total = totals[i] if not total: - sums[i] = alg.nan + sums[i] = math.nan elif i == hue_index: - sums[i] = math.degrees(math.atan2(sin / total, cos / total)) + avg_theta = math.degrees(math.atan2(sin / total, cos / total)) + sums[i] = (avg_theta + 360) if avg_theta < 0 else avg_theta else: sums[i] /= total * alpha if premultiplied else total diff --git a/lib/coloraide/cat.py b/lib/coloraide/cat.py index 1b7b9070..3dee43ae 100644 --- a/lib/coloraide/cat.py +++ b/lib/coloraide/cat.py @@ -1,10 +1,10 @@ """Chromatic adaptation transforms.""" +from __future__ import annotations from . import util from abc import ABCMeta, abstractmethod from . import algebra as alg import functools from .types import Matrix, VectorLike, Vector, Plugin -from typing import Any, Type, Tuple # noqa: F401 # From CIE 2004 Colorimetry T.3 and T.8 # B from https://en.wikipedia.org/wiki/Standard_illuminant#White_point @@ -40,10 +40,10 @@ def calc_adaptation_matrices( - w1: Tuple[float, float], - w2: Tuple[float, float], + w1: tuple[float, float], + w2: tuple[float, float], m: Matrix, -) -> Tuple[Matrix, Matrix]: +) -> tuple[Matrix, Matrix]: """ Get the von Kries based adaptation matrix based on the method and illuminants. @@ -54,12 +54,14 @@ def calc_adaptation_matrices( Granted, we are currently, capped at 20 in the cache, but the average user isn't going to be swapping between over 20 methods and white points in a short period of time. We could always increase the cache if necessary. + + http://www.brucelindbloom.com/index.html?Math.html """ - first = alg.dot(m, util.xy_to_xyz(w1), dims=alg.D2_D1) - second = alg.dot(m, util.xy_to_xyz(w2), dims=alg.D2_D1) - m2 = alg.diag(alg.divide(first, second, dims=alg.D1)) - adapt = alg.dot(alg.solve(m, m2), m) + src = alg.matmul(m, util.xy_to_xyz(w1), dims=alg.D2_D1) + dest = alg.matmul(m, util.xy_to_xyz(w2), dims=alg.D2_D1) + m2 = alg.diag(alg.divide(dest, src, dims=alg.D1)) + adapt = alg.matmul(alg.solve(m, m2), m, dims=alg.D2) return adapt, alg.inv(adapt) @@ -70,7 +72,7 @@ class CAT(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def adapt(self, w1: Tuple[float, float], w2: Tuple[float, float], xyz: VectorLike) -> Vector: + def adapt(self, w1: tuple[float, float], w2: tuple[float, float], xyz: VectorLike) -> Vector: """Adapt a given XYZ color using the provided white points.""" @@ -93,16 +95,23 @@ class VonKries(CAT): @classmethod @functools.lru_cache(maxsize=20) def get_adaptation_matrices( - cls: Type['VonKries'], - w1: Tuple[float, float], - w2: Tuple[float, float] - ) -> Tuple[Matrix, Matrix]: + cls: type[VonKries], + w1: tuple[float, float], + w2: tuple[float, float] + ) -> tuple[Matrix, Matrix]: """Get the adaptation matrices.""" return calc_adaptation_matrices(w1, w2, cls.MATRIX) - def adapt(self, w1: Tuple[float, float], w2: Tuple[float, float], xyz: VectorLike) -> Vector: - """Adapt a given XYZ color using the provided white points.""" + def adapt(self, w1: tuple[float, float], w2: tuple[float, float], xyz: VectorLike) -> Vector: + """ + Adapt a given XYZ color using the provided white points. + + Since we calculate and cache both the forward and inverse matrices, ensure the + calculation between two white points, regardless of which is source, are evaluated + the same. Once the matrices are retrieved, Just make sure we use the correct one + based on which white point is the source. + """ # We are already using the correct white point if w1 == w2: @@ -110,7 +119,7 @@ def adapt(self, w1: Tuple[float, float], w2: Tuple[float, float], xyz: VectorLik a, b = sorted([w1, w2]) m, mi = self.get_adaptation_matrices(a, b) - return alg.dot(mi if a != w2 else m, xyz, dims=alg.D2_D1) + return alg.matmul(mi if a != w1 else m, xyz, dims=alg.D2_D1) class Bradford(VonKries): diff --git a/lib/coloraide/channels.py b/lib/coloraide/channels.py index a8053c73..d21929ae 100644 --- a/lib/coloraide/channels.py +++ b/lib/coloraide/channels.py @@ -1,5 +1,5 @@ """Channels.""" -from typing import Tuple, Optional +from __future__ import annotations FLG_ANGLE = 1 FLG_PERCENT = 2 @@ -10,14 +10,14 @@ class Channel(str): """Channel.""" - # low: float - # high: float - # span: float - # offset: float - # bound: bool - # flags: int - # limit: Tuple[Optional[float], Optional[float]] - # nans: float + low: float + high: float + span: float + offset: float + bound: bool + flags: int + limit: tuple[float | None, float | None] + nans: float def __new__( cls, @@ -27,7 +27,7 @@ def __new__( mirror_range: bool = False, bound: bool = False, flags: int = 0, - limit: Tuple[Optional[float], Optional[float]] = (None, None), + limit: tuple[float | None, float | None] = (None, None), nans: float = 0.0 ) -> 'Channel': """Initialize.""" diff --git a/lib/coloraide/color.py b/lib/coloraide/color.py index 54d49577..d319aed7 100644 --- a/lib/coloraide/color.py +++ b/lib/coloraide/color.py @@ -1,4 +1,5 @@ """Colors.""" +from __future__ import annotations import abc import functools import random @@ -41,6 +42,12 @@ from .spaces.xyz_d50 import XYZD50 from .spaces.oklab.css import Oklab from .spaces.oklch.css import OkLCh +from .spaces.rec2100_pq import Rec2100PQ +from .spaces.rec2100_hlg import Rec2100HLG +from .spaces.rec2100_linear import Rec2100Linear +from .spaces.jzazbz import Jzazbz +from .spaces.jzczhz import JzCzhz +from .spaces.ictcp import ICtCp from .distance import DeltaE from .distance.delta_e_76 import DE76 from .distance.delta_e_94 import DE94 @@ -48,17 +55,23 @@ from .distance.delta_e_2000 import DE2000 from .distance.delta_e_hyab import DEHyAB from .distance.delta_e_ok import DEOK +from .distance.delta_e_itp import DEITP +from .distance.delta_e_z import DEZ from .contrast import ColorContrast from .contrast.wcag21 import WCAG21Contrast from .gamut import Fit from .gamut.fit_lch_chroma import LChChroma from .gamut.fit_oklch_chroma import OkLChChroma +from .gamut.fit_oklch_raytrace import OkLChRayTrace +from .gamut.fit_lch_raytrace import LChRayTrace +from .gamut.fit_raytrace import RayTrace from .cat import CAT, Bradford from .filters import Filter from .filters.w3c_filter_effects import Sepia, Brightness, Contrast, Saturate, Opacity, HueRotate, Grayscale, Invert from .filters.cvd import Protan, Deutan, Tritan from .interpolate import Interpolator, Interpolate from .interpolate.linear import Linear +from .interpolate.css_linear import CSSLinear from .interpolate.continuous import Continuous from .interpolate.bspline import BSpline from .interpolate.bspline_natural import NaturalBSpline @@ -67,16 +80,17 @@ from .temperature.ohno_2013 import Ohno2013 from .temperature.robertson_1968 import Robertson1968 from .types import Plugin -from typing import overload, Union, Sequence, Iterable, Dict, List, Optional, Any, Callable, Tuple, Type, Mapping +from typing import overload, Sequence, Iterable, Any, Callable, Mapping SUPPORTED_CHROMATICITY_SPACES = {'xyz', 'uv-1960', 'uv-1976', 'xy-1931'} + class ColorMatch: """Color match object.""" __slots__ = ('color', 'start', 'end') - def __init__(self, color: 'Color', start: int, end: int) -> None: + def __init__(self, color: Color, start: int, end: int) -> None: """Initialize.""" self.color = color @@ -94,29 +108,29 @@ def __str__(self) -> str: # pragma: no cover class ColorMeta(abc.ABCMeta): """Ensure on subclass that the subclass has new instances of mappings.""" - def __init__(cls, name: str, bases: Tuple[object, ...], clsdict: Dict[str, Any]) -> None: + def __init__(cls, name: str, bases: tuple[object, ...], clsdict: dict[str, Any]) -> None: """Copy mappings on subclass.""" # Ensure subclassed Color objects do not use the same plugin mappings if len(cls.mro()) > 2: - cls.CS_MAP = cls.CS_MAP.copy() # type: Dict[str, Space] - cls.DE_MAP = cls.DE_MAP.copy() # type: Dict[str, DeltaE] - cls.FIT_MAP = cls.FIT_MAP.copy() # type: Dict[str, Fit] - cls.CAT_MAP = cls.CAT_MAP.copy() # type: Dict[str, CAT] - cls.FILTER_MAP = cls.FILTER_MAP.copy() # type: Dict[str, Filter] - cls.CONTRAST_MAP = cls.CONTRAST_MAP.copy() # type: Dict[str, ColorContrast] - cls.INTERPOLATE_MAP = cls.INTERPOLATE_MAP.copy() # type: Dict[str, Interpolate] - cls.CCT_MAP = cls.CCT_MAP.copy() # type: Dict[str, CCT] + cls.CS_MAP = cls.CS_MAP.copy() # type: dict[str, Space] + cls.DE_MAP = cls.DE_MAP.copy() # type: dict[str, DeltaE] + cls.FIT_MAP = cls.FIT_MAP.copy() # type: dict[str, Fit] + cls.CAT_MAP = cls.CAT_MAP.copy() # type: dict[str, CAT] + cls.FILTER_MAP = cls.FILTER_MAP.copy() # type: dict[str, Filter] + cls.CONTRAST_MAP = cls.CONTRAST_MAP.copy() # type: dict[str, ColorContrast] + cls.INTERPOLATE_MAP = cls.INTERPOLATE_MAP.copy() # type: dict[str, Interpolate] + cls.CCT_MAP = cls.CCT_MAP.copy() # type: dict[str, CCT] # Ensure each derived class tracks its own conversion paths for color spaces # relative to the installed color space plugins. @classmethod # type: ignore[misc] @functools.lru_cache(maxsize=256) def _get_convert_chain( - cls: Type['Color'], - space: 'Space', + cls: type[Color], + space: Space, target: str - ) -> List[Tuple['Space', 'Space', int, bool]]: + ) -> list[tuple[Space, Space, int, bool]]: """Resolve a conversion chain, cache it for speed.""" return convert.get_convert_chain(cls, space, target) @@ -127,17 +141,18 @@ def _get_convert_chain( class Color(metaclass=ColorMeta): """Color class object which provides access and manipulation of color spaces.""" - CS_MAP = {} # type: Dict[str, Space] - DE_MAP = {} # type: Dict[str, DeltaE] - FIT_MAP = {} # type: Dict[str, Fit] - CAT_MAP = {} # type: Dict[str, CAT] - CONTRAST_MAP = {} # type: Dict[str, ColorContrast] - FILTER_MAP = {} # type: Dict[str, Filter] - INTERPOLATE_MAP = {} # type: Dict[str, Interpolate] - CCT_MAP = {} # type: Dict[str, CCT] + CS_MAP = {} # type: dict[str, Space] + DE_MAP = {} # type: dict[str, DeltaE] + FIT_MAP = {} # type: dict[str, Fit] + CAT_MAP = {} # type: dict[str, CAT] + CONTRAST_MAP = {} # type: dict[str, ColorContrast] + FILTER_MAP = {} # type: dict[str, Filter] + INTERPOLATE_MAP = {} # type: dict[str, Interpolate] + CCT_MAP = {} # type: dict[str, CCT] PRECISION = util.DEF_PREC FIT = util.DEF_FIT INTERPOLATE = util.DEF_INTERPOLATE + INTERPOLATOR = util.DEF_INTERPOLATOR DELTA_E = util.DEF_DELTA_E HARMONY = util.DEF_HARMONY AVERAGE = util.DEF_AVERAGE @@ -160,7 +175,7 @@ class Color(metaclass=ColorMeta): def __init__( self, color: ColorInput, - data: Optional[VectorLike] = None, + data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, **kwargs: Any ) -> None: @@ -174,27 +189,27 @@ def __len__(self) -> int: return len(self._space.CHANNELS) + 1 @overload - def __getitem__(self, i: Union[str, int]) -> float: # noqa: D105 + def __getitem__(self, i: str | int) -> float: ... @overload - def __getitem__(self, i: slice) -> Vector: # noqa: D105 + def __getitem__(self, i: slice) -> Vector: ... - def __getitem__(self, i: Union[str, int, slice]) -> Union[float, Vector]: + def __getitem__(self, i: str | int | slice) -> float | Vector: """Get channels.""" return self._coords[self._space.get_channel_index(i)] if isinstance(i, str) else self._coords[i] @overload - def __setitem__(self, i: Union[str, int], v: float) -> None: # noqa: D105 + def __setitem__(self, i: str | int, v: float) -> None: ... @overload - def __setitem__(self, i: slice, v: Vector) -> None: # noqa: D105 + def __setitem__(self, i: slice, v: Vector) -> None: ... - def __setitem__(self, i: Union[str, int, slice], v: Union[float, Vector]) -> None: + def __setitem__(self, i: str | int | slice, v: float | Vector) -> None: """Set channels.""" space = self._space @@ -218,10 +233,10 @@ def __eq__(self, other: Any) -> bool: def _parse( cls, color: ColorInput, - data: Optional[VectorLike] = None, + data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, **kwargs: Any - ) -> Tuple[Space, List[float]]: + ) -> tuple[Space, Vector]: """Parse the color.""" # Parse a color string or color space name and coordinates @@ -236,7 +251,7 @@ def _parse( num_channels = len(space_class.CHANNELS) num_data = len(data) if num_data < num_channels: - data = list(data) + [alg.nan] * (num_channels - num_data) + data = list(data) + [math.nan] * (num_channels - num_data) coords = [alg.clamp(float(v), *c.limit) for c, v in zipl(space_class.CHANNELS, data)] coords.append(alg.clamp(float(alpha), *space_class.channels[-1].limit)) obj = space_class, coords @@ -272,7 +287,7 @@ def _match( string: str, start: int = 0, fullmatch: bool = False - ) -> Optional[Tuple['Space', Vector, float, int, int]]: + ) -> tuple[Space, Vector, float, int, int] | None: """ Match a color in a buffer and return a color object. @@ -301,7 +316,7 @@ def match( string: str, start: int = 0, fullmatch: bool = False - ) -> Optional[ColorMatch]: + ) -> ColorMatch | None: """Match color.""" m = cls._match(string, start, fullmatch) @@ -324,7 +339,7 @@ def _is_color(cls, obj: Any) -> bool: @classmethod def register( cls, - plugin: Union[Plugin, Sequence[Plugin]], + plugin: Plugin | Sequence[Plugin], *, overwrite: bool = False, silent: bool = False @@ -382,7 +397,7 @@ def register( cls._get_convert_chain.cache_clear() @classmethod - def deregister(cls, plugin: Union[str, Sequence[str]], *, silent: bool = False) -> None: + def deregister(cls, plugin: str | Sequence[str], *, silent: bool = False) -> None: """Deregister a plugin by name of specified plugin type.""" reset_convert_cache = False @@ -390,7 +405,7 @@ def deregister(cls, plugin: Union[str, Sequence[str]], *, silent: bool = False) if isinstance(plugin, str): plugin = [plugin] - mapping = None # type: Optional[Dict[str, Any]] + mapping = None # type: dict[str, Any] | None for p in plugin: if p == '*': cls.CS_MAP.clear() @@ -447,7 +462,7 @@ def deregister(cls, plugin: Union[str, Sequence[str]], *, silent: bool = False) cls._get_convert_chain.cache_clear() @classmethod - def random(cls, space: str, *, limits: Optional[Sequence[Optional[Sequence[float]]]] = None) -> 'Color': + def random(cls, space: str, *, limits: Sequence[Sequence[float] | None] | None = None) -> Color: """Get a random color.""" # Get the color space and number of channels @@ -473,7 +488,7 @@ def random(cls, space: str, *, limits: Optional[Sequence[Optional[Sequence[float # Create the color obj = cls(space, coords) - if hasattr(obj._space, 'hue_index'): + if obj._space.is_polar(): obj.normalize() return obj @@ -485,10 +500,10 @@ def blackbody( duv: float = 0.0, *, scale: bool = True, - scale_space: Optional[str] = None, - method: Optional[str] = None, + scale_space: str | None = None, + method: str | None = None, **kwargs: Any - ) -> 'Color': + ) -> Color: """ Get a color along the black body curve. @@ -506,7 +521,7 @@ def blackbody( color = cct.from_cct(cls, space, temp, duv, scale, scale_space, **kwargs) return color - def cct(self, *, method: Optional[str] = None, **kwargs: Any) -> Vector: + def cct(self, *, method: str | None = None, **kwargs: Any) -> Vector: """Get color temperature.""" cct = temperature.cct(method, self) @@ -517,13 +532,13 @@ def to_dict(self, *, nans: bool = True) -> Mapping[str, Any]: return {'space': self.space(), 'coords': self.coords(nans=nans), 'alpha': self.alpha(nans=nans)} - def normalize(self, *, nans: bool = True) -> 'Color': + def normalize(self, *, nans: bool = True) -> Color: """Normalize the color.""" - self[:-1] = self.coords(nans=False) - if nans and hasattr(self._space, 'hue_index') and self.is_achromatic(): - i = self._space.hue_index() - self[i] = alg.nan + self[:-1] = self._space.normalize(self.coords(nans=False)) + if nans and self._space.is_polar() and self.is_achromatic(): + i = self._space.hue_index() # type: ignore[attr-defined] + self[i] = math.nan alpha = self[-1] self[-1] = 0.0 if math.isnan(alpha) else alpha return self @@ -533,7 +548,7 @@ def is_nan(self, name: str) -> bool: # pragma: no cover return math.isnan(self.get(name)) - def _handle_color_input(self, color: ColorInput) -> 'Color': + def _handle_color_input(self, color: ColorInput) -> Color: """Handle color input.""" if isinstance(color, (str, Mapping)): @@ -551,15 +566,15 @@ def space(self) -> str: def new( self, color: ColorInput, - data: Optional[VectorLike] = None, + data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, **kwargs: Any - ) -> 'Color': + ) -> Color: """Create new color object.""" return type(self)(color, data, alpha, **kwargs) - def clone(self) -> 'Color': + def clone(self) -> Color: """Clone.""" return self.new(self.space(), self[:-1], self[-1]) @@ -568,10 +583,10 @@ def convert( self, space: str, *, - fit: Union[bool, str] = False, + fit: bool | str = False, in_place: bool = False, norm: bool = True - ) -> 'Color': + ) -> Color: """Convert to color space.""" # Convert the color and then fit it. @@ -579,7 +594,7 @@ def convert( method = None if not isinstance(fit, str) else fit if not self.in_gamut(space, tolerance=0.0): converted = self.convert(space, in_place=in_place, norm=norm) - return converted.fit(space, method=method) + return converted.fit(method=method) # Nothing to do, just return the color with no alterations. if space == self.space(): @@ -592,8 +607,8 @@ def convert( this._coords[:-1] = coords # Normalize achromatic colors, but skip if we internally don't need this. - if norm and hasattr(this._space, 'hue_index') and this.is_achromatic(): - this[this._space.hue_index()] = alg.nan + if norm and this._space.is_polar() and this.is_achromatic(): + this[this._space.hue_index()] = math.nan # type: ignore[attr-defined] return this @@ -609,10 +624,10 @@ def is_achromatic(self) -> bool: def mutate( self, color: ColorInput, - data: Optional[VectorLike] = None, + data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, **kwargs: Any - ) -> 'Color': + ) -> Color: """Mutate the current color to a new color.""" self._space, self._coords = self._parse(color, data=data, alpha=alpha, **kwargs) @@ -621,12 +636,12 @@ def mutate( def update( self, color: ColorInput, - data: Optional[VectorLike] = None, + data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, *, norm: bool = True, **kwargs: Any - ) -> 'Color': + ) -> Color: """Update the existing color space with the provided color.""" space = self.space() @@ -635,7 +650,7 @@ def update( self.convert(space, in_place=True, norm=norm) return self - def _hotswap(self, color: 'Color') -> 'Color': + def _hotswap(self, color: Color) -> Color: """ Hot swap a color object. @@ -667,12 +682,12 @@ def white(self, cspace: str = 'xyz') -> Vector: value = self.convert_chromaticity('xy-1931', cspace, self._space.WHITE) return value if cspace == 'xyz' else value[:-1] - def uv(self, mode: str = '1976', *, white: Optional[VectorLike] = None) -> Vector: + def uv(self, mode: str = '1976', *, white: VectorLike | None = None) -> Vector: """Convert to `xy`.""" return self.split_chromaticity('uv-' + mode)[:-1] - def xy(self, *, white: Optional[VectorLike] = None) -> Vector: + def xy(self, *, white: VectorLike | None = None) -> Vector: """Convert to `xy`.""" return self.split_chromaticity('xy-1931')[:-1] @@ -681,7 +696,7 @@ def split_chromaticity( self, cspace: str = 'uv-1976', *, - white: Optional[VectorLike] = None + white: VectorLike | None = None ) -> Vector: """ Split a color into chromaticity and luminance coordinates. @@ -719,9 +734,9 @@ def chromaticity( cspace: str = 'uv-1976', *, scale: bool = False, - scale_space: Optional[str] = None, - white: Optional[VectorLike] = None - ) -> 'Color': + scale_space: str | None = None, + white: VectorLike | None = None + ) -> Color: """ Create a color from chromaticity coordinates. @@ -772,7 +787,7 @@ def convert_chromaticity( cspace2: str, coords: VectorLike, *, - white: Optional[VectorLike] = None + white: VectorLike | None = None ) -> Vector: """ Convert to or from chromaticity coordinates or between other chromaticity coordinates. @@ -835,7 +850,7 @@ def chromatic_adaptation( w2: VectorLike, xyz: VectorLike, *, - method: Optional[str] = None + method: str | None = None ) -> Vector: """Chromatic adaptation.""" @@ -845,27 +860,45 @@ def chromatic_adaptation( return adapter.adapt(tuple(w1), tuple(w2), xyz) # type: ignore[arg-type] - def clip(self, space: Optional[str] = None) -> 'Color': + def clip(self, space: str | None = None) -> Color: """Clip the color channels.""" orig_space = self.space() - if space is None: - space = self.space() + target_space = space or orig_space - # Convert to desired space - c = self.convert(space, in_place=True, norm=False) - gamut.clip_channels(c) + # We are indirectly clipping this space + if orig_space != target_space: + return self.convert(target_space, norm=False, in_place=True).clip().convert(orig_space, in_place=True) - # Adjust "this" color - return c.convert(orig_space, in_place=True) + # Determine what space we actually need to clip in + if space is None: + space = self._space.CLIP_SPACE or self._space.GAMUT_CHECK or orig_space + else: + cs = self.CS_MAP[space] + space = cs.CLIP_SPACE or cs.GAMUT_CHECK or cs.NAME + + # Convert to desired space and clip the color + if space != orig_space: + conv = self.convert(space, norm=False) + if not gamut.clip_channels(conv): + # Clipping only made non-essential changes (normalize hue), + # just clip in the current space to preserve 'None' and clean up noise + # at color space boundary limits (if any). + gamut.clip_channels(self) + return self + # Copy results to current color. + return self._hotswap(conv.convert(orig_space, in_place=True)) + + gamut.clip_channels(self) + return self def fit( self, - space: Optional[str] = None, + space: str | None = None, *, - method: Optional[str] = None, + method: str | None = None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Fit the gamut using the provided method.""" if method is None: @@ -875,9 +908,17 @@ def fit( if method == 'clip': return self.clip(space) - orig_space = self.space() + # If within gamut, just normalize hue range by calling clip. + if self.in_gamut(space, tolerance=0): + self.clip(space) + return self + + # Determine what space we actually need to gamut map in if space is None: - space = self.space() + target = self._space.GAMUT_CHECK or self.space() + else: + cs = self.CS_MAP[space] + target = cs.GAMUT_CHECK or cs.NAME # Select appropriate mapping algorithm mapping = self.FIT_MAP.get(method) @@ -885,21 +926,10 @@ def fit( # Unknown fit method raise ValueError("'{}' gamut mapping is not currently supported".format(method)) - # Convert to desired space - self.convert(space, in_place=True, norm=False) - - # If within gamut, just normalize hue range by calling clip. - if self.in_gamut(tolerance=0): - gamut.clip_channels(self) - - # Perform gamut mapping. - else: - mapping.fit(self, **kwargs) - - # Convert back to the original color space - return self.convert(orig_space, in_place=True) + mapping.fit(self, target, **kwargs) + return self - def in_gamut(self, space: Optional[str] = None, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool: + def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool: """Check if current color is in gamut.""" if space is None: @@ -924,12 +954,12 @@ def in_pointer_gamut(self, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool return gamut.pointer.in_pointer_gamut(self, tolerance) - def fit_pointer_gamut(self) -> 'Color': + def fit_pointer_gamut(self) -> Color: """Check if in pointer gamut.""" return gamut.pointer.fit_pointer_gamut(self) - def mask(self, channel: Union[str, Sequence[str]], *, invert: bool = False, in_place: bool = False) -> 'Color': + def mask(self, channel: str | Sequence[str], *, invert: bool = False, in_place: bool = False) -> Color: """Mask color channels.""" this = self if in_place else self.clone() @@ -939,7 +969,7 @@ def mask(self, channel: Union[str, Sequence[str]], *, invert: bool = False, in_p ) for name in self._space.channels: if (not invert and name in masks) or (invert and name not in masks): - this[name] = alg.nan + this[name] = math.nan return this def mix( @@ -949,7 +979,7 @@ def mix( *, in_place: bool = False, **interpolate_args: Any - ) -> 'Color': + ) -> Color: """ Mix colors using interpolation. @@ -970,14 +1000,15 @@ def mix( @classmethod def steps( cls, - colors: Sequence[Union[ColorInput, interpolate.stop, Callable[..., float]]], + colors: Sequence[ColorInput | interpolate.stop | Callable[..., float]], *, steps: int = 2, max_steps: int = 1000, max_delta_e: float = 0, - delta_e: Optional[str] = None, + delta_e: str | None = None, + delta_e_args: dict[str, Any] | None = None, **interpolate_args: Any - ) -> List['Color']: + ) -> list[Color]: """Discrete steps.""" # Scale really needs to be between 0 and 1 or steps will break @@ -985,20 +1016,21 @@ def steps( if domain is not None: interpolate_args['domain'] = interpolate.normalize_domain(domain) - return cls.interpolate(colors, **interpolate_args).steps(steps, max_steps, max_delta_e, delta_e) + return cls.interpolate(colors, **interpolate_args).steps(steps, max_steps, max_delta_e, delta_e, delta_e_args) @classmethod def discrete( cls, - colors: Sequence[Union[ColorInput, interpolate.stop, Callable[..., float]]], + colors: Sequence[ColorInput | interpolate.stop | Callable[..., float]], *, - space: Union[str, None] = None, - out_space: Union[str, None] = None, - steps: Union[int, None] = None, + space: str | None = None, + out_space: str | None = None, + steps: int | None = None, max_steps: int = 1000, max_delta_e: float = 0, - delta_e: Union[str, None] = None, - domain: Optional[List[float]] = None, + delta_e: str | None = None, + delta_e_args: dict[str, Any] | None = None, + domain: Vector | None = None, **interpolate_args: Any ) -> Interpolator: """Create a discrete interpolation.""" @@ -1007,7 +1039,7 @@ def discrete( num = sum((not callable(c) or not isinstance(c, interpolate.stop)) for c in colors) if steps is None else steps i = cls.interpolate(colors, space=space, **interpolate_args) # Convert the interpolation into a discretized interpolation with the requested number of steps - i.discretize(num, max_steps, max_delta_e, delta_e) + i = i.discretize(num, max_steps, max_delta_e, delta_e, delta_e_args) if domain is not None: i.domain(domain) if out_space is not None: @@ -1017,19 +1049,19 @@ def discrete( @classmethod def interpolate( cls, - colors: Sequence[Union[ColorInput, interpolate.stop, Callable[..., float]]], + colors: Sequence[ColorInput | interpolate.stop | Callable[..., float]], *, - space: Optional[str] = None, - out_space: Optional[str] = None, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]] = None, + space: str | None = None, + out_space: str | None = None, + progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None = None, hue: str = util.DEF_HUE_ADJ, premultiplied: bool = True, extrapolate: bool = False, - domain: Optional[List[float]] = None, - method: str = "linear", - padding: Optional[Union[float, Tuple[float, float]]] = None, - carryforward: Optional[bool] = None, - powerless: Optional[bool] = None, + domain: Vector | None = None, + method: str | None = None, + padding: float | tuple[float, float] | None = None, + carryforward: bool | None = None, + powerless: bool | None = None, **kwargs: Any ) -> Interpolator: """ @@ -1045,7 +1077,7 @@ def interpolate( """ return interpolate.interpolator( - method, + method if method is not None else cls.INTERPOLATOR, cls, colors=colors, space=space, @@ -1066,12 +1098,12 @@ def average( cls, colors: Iterable[ColorInput], *, - space: Optional[str] = None, - out_space: Optional[str] = None, + space: str | None = None, + out_space: str | None = None, premultiplied: bool = True, - powerless: Optional[bool] = None, + powerless: bool | None = None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Average the colors.""" if space is None: @@ -1091,13 +1123,13 @@ def average( def filter( # noqa: A003 self, name: str, - amount: Optional[float] = None, + amount: float | None = None, *, - space: Optional[str] = None, - out_space: Optional[str] = None, + space: str | None = None, + out_space: str | None = None, in_place: bool = False, **kwargs: Any - ) -> 'Color': + ) -> Color: """Filter.""" return filters.filters(self, name, amount, space, out_space, in_place, **kwargs) @@ -1106,9 +1138,10 @@ def harmony( self, name: str, *, - space: Optional[str] = None, - out_space: Optional[str] = None - ) -> List['Color']: + space: str | None = None, + out_space: str | None = None, + **kwargs: Any + ) -> list[Color]: """Acquire the specified color harmonies.""" if space is None: @@ -1121,14 +1154,14 @@ def harmony( def compose( self, - backdrop: Union[ColorInput, Sequence[ColorInput]], + backdrop: ColorInput | Sequence[ColorInput], *, - blend: Union[str, bool] = True, - operator: Union[str, bool] = True, - space: Optional[str] = None, - out_space: Optional[str] = None, + blend: str | bool = True, + operator: str | bool = True, + space: str | None = None, + out_space: str | None = None, in_place: bool = False - ) -> 'Color': + ) -> Color: """Blend colors using the specified blend mode.""" if not isinstance(backdrop, str) and isinstance(backdrop, Sequence): @@ -1143,7 +1176,7 @@ def delta_e( self, color: ColorInput, *, - method: Optional[str] = None, + method: str | None = None, **kwargs: Any ) -> float: """Delta E distance.""" @@ -1166,14 +1199,14 @@ def closest( self, colors: Sequence[ColorInput], *, - method: Optional[str] = None, + method: str | None = None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Find the closest color to the current base color.""" return distance.closest(self, colors, method=method, **kwargs) - def luminance(self, *, white: Optional[VectorLike] = cat.WHITES['2deg']['D65']) -> float: + def luminance(self, *, white: VectorLike | None = cat.WHITES['2deg']['D65']) -> float: """Get color's luminance.""" if white is None: @@ -1190,7 +1223,7 @@ def luminance(self, *, white: Optional[VectorLike] = cat.WHITES['2deg']['D65']) return coords[1] - def contrast(self, color: ColorInput, method: Optional[str] = None) -> float: + def contrast(self, color: ColorInput, method: str | None = None) -> float: """Compare the contrast ratio of this color and the provided color.""" color = self._handle_color_input(color) @@ -1201,10 +1234,10 @@ def get(self, name: str, *, nans: bool = True) -> float: ... @overload - def get(self, name: Union[List[str], Tuple[str, ...]], *, nans: bool = True) -> List[float]: + def get(self, name: list[str] | tuple[str, ...], *, nans: bool = True) -> Vector: ... - def get(self, name: Union[str, List[str], Tuple[str, ...]], *, nans: bool = True) -> Union[float, List[float]]: + def get(self, name: str | list[str] | tuple[str, ...], *, nans: bool = True) -> float | Vector: """Get channel.""" # Handle single channel @@ -1245,11 +1278,11 @@ def get(self, name: Union[str, List[str], Tuple[str, ...]], *, nans: bool = True def set( # noqa: A003 self, - name: Union[str, Dict[str, Union[float, Callable[..., float]]]], - value: Optional[Union[float, Callable[..., float]]] = None, + name: str | dict[str, float | Callable[..., float]], + value: float | Callable[..., float] | None = None, *, nans: bool = True - ) -> 'Color': + ) -> Color: """Set channel.""" # Set all the channels in a dictionary. @@ -1334,11 +1367,17 @@ def alpha(self, *, nans: bool = True) -> float: LCh(), LabD65(), LChD65(), + Jzazbz(), + JzCzhz(), + ICtCp(), HSV(), HSL(), HWB(), Rec2020(), Rec2020Linear(), + Rec2100PQ(), + Rec2100HLG(), + Rec2100Linear(), A98RGB(), A98RGBLinear(), ProPhotoRGB(), @@ -1354,10 +1393,15 @@ def alpha(self, *, nans: bool = True) -> float: DE2000(), DEHyAB(), DEOK(), + DEITP(), + DEZ(), # Fit LChChroma(), OkLChChroma(), + RayTrace(), + LChRayTrace(), + OkLChRayTrace(), # Filters Sepia(), @@ -1377,6 +1421,7 @@ def alpha(self, *, nans: bool = True) -> float: # Interpolation Linear(), + CSSLinear(), Continuous(), BSpline(), NaturalBSpline(), diff --git a/lib/coloraide/compositing/__init__.py b/lib/coloraide/compositing/__init__.py index e3aa6e85..b777a212 100644 --- a/lib/coloraide/compositing/__init__.py +++ b/lib/coloraide/compositing/__init__.py @@ -3,12 +3,13 @@ https://www.w3.org/TR/compositing/ """ +from __future__ import annotations from .. spaces import RGBish from . import porter_duff from . import blend_modes from .. import algebra as alg from ..channels import Channel -from typing import Optional, Union, List, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -17,8 +18,8 @@ def clip_channel(coord: float, channel: Channel) -> float: """Clipping channel.""" - a = channel.low # type: Optional[float] - b = channel.high # type: Optional[float] + a = channel.low # type: float | None + b = channel.high # type: float | None # These parameters are unbounded if not channel.bound: # pragma: no cover @@ -32,11 +33,11 @@ def clip_channel(coord: float, channel: Channel) -> float: def apply_compositing( - color1: 'Color', - color2: 'Color', - blender: Optional[blend_modes.Blend], - operator: Union[str, bool] -) -> 'Color': + color1: Color, + color2: Color, + blender: blend_modes.Blend | None, + operator: str | bool +) -> Color: """Perform the actual blending.""" # Get the color coordinates @@ -46,7 +47,7 @@ def apply_compositing( coords2 = color2.coords(nans=False) # Setup compositing - compositor = None # type: Optional[porter_duff.PorterDuff] + compositor = None # type: porter_duff.PorterDuff | None cra = csa if isinstance(operator, str): compositor = porter_duff.compositor(operator)(cba, csa) @@ -75,17 +76,17 @@ def apply_compositing( def compose( - color: 'Color', - backdrop: List['Color'], - blend: Union[str, bool] = True, - operator: Union[str, bool] = True, - space: Optional[str] = None, - out_space: Optional[str] = None -) -> 'Color': + color: Color, + backdrop: list[Color], + blend: str | bool = True, + operator: str | bool = True, + space: str | None = None, + out_space: str | None = None +) -> Color: """Blend colors using the specified blend mode.""" # We need to go ahead and grab the blender as we need to check what type of blender it is. - blender = None # Optional[blend_modes.Blend] + blender = None # blend_modes.Blend | None if isinstance(blend, str): blender = blend_modes.get_blender(blend) elif blend is True: diff --git a/lib/coloraide/compositing/blend_modes.py b/lib/coloraide/compositing/blend_modes.py index f7438c3e..d142934b 100644 --- a/lib/coloraide/compositing/blend_modes.py +++ b/lib/coloraide/compositing/blend_modes.py @@ -1,8 +1,8 @@ """Blend modes.""" +from __future__ import annotations import math from abc import ABCMeta, abstractmethod from operator import itemgetter -from typing import Dict from ..types import Vector @@ -298,7 +298,7 @@ def apply(self, cb: Vector, cs: Vector) -> Vector: "saturation": BlendSaturation(), "luminosity": BlendLuminosity(), "color": BlendColor(), -} # type: Dict[str, Blend] +} # type: dict[str, Blend] def get_blender(blend: str) -> Blend: diff --git a/lib/coloraide/compositing/porter_duff.py b/lib/coloraide/compositing/porter_duff.py index 1be4c7cb..f32500fc 100644 --- a/lib/coloraide/compositing/porter_duff.py +++ b/lib/coloraide/compositing/porter_duff.py @@ -1,6 +1,6 @@ """Porter Duff compositing.""" +from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import Type class PorterDuff(metaclass=ABCMeta): @@ -234,7 +234,7 @@ def fb(self) -> float: } -def compositor(name: str) -> Type[PorterDuff]: +def compositor(name: str) -> type[PorterDuff]: """Get the requested compositor.""" composite = SUPPORTED.get(name) diff --git a/lib/coloraide/contrast/__init__.py b/lib/coloraide/contrast/__init__.py index 1655c957..efc4157a 100644 --- a/lib/coloraide/contrast/__init__.py +++ b/lib/coloraide/contrast/__init__.py @@ -1,7 +1,8 @@ """Contrast.""" +from __future__ import annotations from abc import ABCMeta, abstractmethod from ..types import Plugin -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -13,11 +14,11 @@ class ColorContrast(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def contrast(self, color1: 'Color', color2: 'Color', **kwargs: Any) -> float: + def contrast(self, color1: Color, color2: Color, **kwargs: Any) -> float: """Get the contrast of the two provided colors.""" -def contrast(name: Optional[str], color1: 'Color', color2: 'Color', **kwargs: Any) -> float: +def contrast(name: str | None, color1: Color, color2: Color, **kwargs: Any) -> float: """Get the appropriate contrast plugin.""" if name is None: diff --git a/lib/coloraide/contrast/lstar.py b/lib/coloraide/contrast/lstar.py index 0674fb57..74c3ecdc 100644 --- a/lib/coloraide/contrast/lstar.py +++ b/lib/coloraide/contrast/lstar.py @@ -5,6 +5,7 @@ https://material.io/blog/science-of-color-design """ +from __future__ import annotations from ..contrast import ColorContrast from typing import Any, TYPE_CHECKING @@ -17,7 +18,7 @@ class LstarContrast(ColorContrast): NAME = "lstar" - def contrast(self, color1: 'Color', color2: 'Color', **kwargs: Any) -> float: + def contrast(self, color1: Color, color2: Color, **kwargs: Any) -> float: """Contrast.""" l1 = color1.get('lch-d65.lightness', nans=False) diff --git a/lib/coloraide/contrast/wcag21.py b/lib/coloraide/contrast/wcag21.py index 5e0e557d..e8a9a381 100644 --- a/lib/coloraide/contrast/wcag21.py +++ b/lib/coloraide/contrast/wcag21.py @@ -3,6 +3,7 @@ https://www.w3.org/TR/WCAG20/#contrast-ratiodef """ +from __future__ import annotations from ..contrast import ColorContrast from typing import Any, TYPE_CHECKING @@ -15,7 +16,7 @@ class WCAG21Contrast(ColorContrast): NAME = "wcag21" - def contrast(self, color1: 'Color', color2: 'Color', **kwargs: Any) -> float: + def contrast(self, color1: Color, color2: Color, **kwargs: Any) -> float: """Contrast.""" lum1 = max(0, color1.luminance()) diff --git a/lib/coloraide/convert.py b/lib/coloraide/convert.py index c6dcf9f3..977dcdd1 100644 --- a/lib/coloraide/convert.py +++ b/lib/coloraide/convert.py @@ -1,7 +1,7 @@ """Convert the color.""" -from . import algebra as alg +from __future__ import annotations from .types import Vector -from typing import Type, Tuple, Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from .color import Color @@ -13,9 +13,9 @@ def calc_path_to_xyz( - color: Type['Color'], + color: type[Color], space: str -) -> Tuple[List['Space'], Dict[str, int]]: +) -> tuple[list[Space], dict[str, int]]: """ Calculate the conversion path between a given color space and XYZ D65. @@ -53,10 +53,10 @@ def calc_path_to_xyz( def get_convert_chain( - color: Type['Color'], - space: 'Space', + color: type[Color], + space: Space, target: str -) -> List[Tuple['Space', 'Space', int, bool]]: +) -> list[tuple[Space, Space, int, bool]]: """ Create a conversion chain. @@ -74,7 +74,7 @@ def get_convert_chain( # If the color space we are converting to is not between # the current space and XYZ D65, nothing will get added. current = space - chain = [] # type: List[Tuple['Space', 'Space', int, bool]] + chain = [] # type: list[tuple[Space, Space, int, bool]] if current.NAME != ABSOLUTE_BASE: count = 0 while current.NAME not in from_color_index: @@ -119,7 +119,7 @@ def get_convert_chain( return chain -def convert(color: 'Color', space: str) -> Tuple['Space', Vector]: +def convert(color: Color, space: str) -> tuple[Space, Vector]: """Convert the color coordinates to the specified space.""" # Grab the convert for the current space to the desired space diff --git a/lib/coloraide/css/color_names.py b/lib/coloraide/css/color_names.py index 5758ca13..04d52076 100644 --- a/lib/coloraide/css/color_names.py +++ b/lib/coloraide/css/color_names.py @@ -5,7 +5,7 @@ http://www.w3.org/TR/SVG/types.html#ColorKeywords """ -from typing import Optional, Tuple, Dict +from __future__ import annotations from .. import algebra as alg from ..types import Vector @@ -161,18 +161,18 @@ # Transparent 'transparent': (0.0, 0.0, 0.0, 0.0) -} # type: Dict[str, Tuple[float, ...]] +} # type: dict[str, tuple[float, ...]] -val2name_map = {v: k for k, v in name2val_map.items()} # type: Dict[Tuple[float, ...], str] +val2name_map = {v: k for k, v in name2val_map.items()} # type: dict[tuple[float, ...], str] -def to_name(value: Vector) -> Optional[str]: +def to_name(value: Vector) -> str | None: """Convert CSS hex to webcolor name.""" return val2name_map.get(tuple(alg.round_half_up(c * 255) for c in value), None) -def from_name(name: str) -> Optional[Vector]: +def from_name(name: str) -> Vector | None: """Convert CSS hex to webcolor name.""" value = name2val_map.get(name.lower(), None) diff --git a/lib/coloraide/css/parse.py b/lib/coloraide/css/parse.py index 874bee7b..4b5a05cd 100644 --- a/lib/coloraide/css/parse.py +++ b/lib/coloraide/css/parse.py @@ -1,12 +1,12 @@ """Parse utilities.""" +from __future__ import annotations import re import math from .. import algebra as alg from ..types import Vector from . import color_names from ..channels import Channel, FLG_ANGLE -from typing import Optional, Tuple -from typing import List, Dict, Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any import functools if TYPE_CHECKING: # pragma: no cover @@ -37,7 +37,7 @@ def norm_float(string: str) -> float: """Normalize a float value.""" if string == "none": - return alg.nan + return math.nan return float(string) @@ -114,7 +114,7 @@ def norm_angle_channel(angle: str) -> float: return value -def parse_hex(color: str) -> Tuple[Vector, float]: +def parse_hex(color: str) -> tuple[Vector, float]: """Parse hexadecimal color.""" length = len(color) @@ -138,7 +138,7 @@ def parse_hex(color: str) -> Tuple[Vector, float]: ) -def parse_rgb_channels(color: List[str], boundry: Tuple[Channel, ...]) -> Tuple[Vector, float]: +def parse_rgb_channels(color: list[str], boundry: tuple[Channel, ...]) -> tuple[Vector, float]: """Parse CSS RGB format.""" channels = [] @@ -152,7 +152,7 @@ def parse_rgb_channels(color: List[str], boundry: Tuple[Channel, ...]) -> Tuple[ return channels, alpha -def parse_channels(color: List[str], boundry: Tuple[Channel, ...], scaled: bool = False) -> Tuple[Vector, float]: +def parse_channels(color: list[str], boundry: tuple[Channel, ...], scaled: bool = False) -> tuple[Vector, float]: """Parse CSS channel format.""" channels = [] @@ -173,7 +173,7 @@ def parse_channels(color: List[str], boundry: Tuple[Channel, ...], scaled: bool return channels, alpha -def parse_color(tokens: Dict[str, Any], space: 'Space') -> Optional[Tuple[Vector, float]]: +def parse_color(tokens: dict[str, Any], space: Space) -> tuple[Vector, float] | None: """Parse the color function.""" # Iterate the spaces and see if we find the color serialization identifier @@ -193,17 +193,20 @@ def parse_color(tokens: Dict[str, Any], space: 'Space') -> Optional[Tuple[Vector for i in range(num_channels): c = tokens['func']['values'][i]['value'] channel = properties[i] - channels.append(norm_color_channel(c.lower(), channel.span, channel.offset)) + if channel.flags & FLG_ANGLE: + channels.append(norm_angle_channel(c)) + else: + channels.append(norm_color_channel(c.lower(), channel.span, channel.offset)) return (channels, alpha) -def validate_color(tokens: Dict[str, Any]) -> bool: +def validate_color(tokens: dict[str, Any]) -> bool: """Validate the color function syntax.""" - return not any(v['type'] == 'degree' for v in tokens['func']['values']) + return True -def validate_srgb(tokens: Dict[str, Any]) -> bool: +def validate_srgb(tokens: dict[str, Any]) -> bool: """Validate the RGB color functions.""" length = len(tokens['func']['values']) @@ -225,7 +228,7 @@ def validate_srgb(tokens: Dict[str, Any]) -> bool: return True -def validate_cylindrical_srgb(tokens: Dict[str, Any]) -> bool: +def validate_cylindrical_srgb(tokens: dict[str, Any]) -> bool: """Validate cylindrical sRGB.""" length = len(tokens['func']['values']) @@ -256,7 +259,7 @@ def validate_cylindrical_srgb(tokens: Dict[str, Any]) -> bool: return True -def validate_lab(tokens: Dict[str, Any]) -> bool: +def validate_lab(tokens: dict[str, Any]) -> bool: """Validate CSS Lab variant color spaces.""" length = len(tokens['func']['values']) @@ -277,7 +280,7 @@ def validate_lab(tokens: Dict[str, Any]) -> bool: return True -def validate_lch(tokens: Dict[str, Any]) -> bool: +def validate_lch(tokens: dict[str, Any]) -> bool: """Validate CSS LCh variant color spaces.""" length = len(tokens['func']['values']) @@ -302,10 +305,10 @@ def validate_lch(tokens: Dict[str, Any]) -> bool: @functools.lru_cache(maxsize=1) -def tokenize_css(css: str, start: int = 0) -> Dict[str, Any]: +def tokenize_css(css: str, start: int = 0) -> dict[str, Any]: """Tokenize the CSS string.""" - tokens = {} # type: Dict[str, Any] + tokens = {} # type: dict[str, Any] # `mypy` will get confused, just set to Any m = RE_HEX.match(css, start) # type: Any if m: @@ -410,7 +413,7 @@ def tokenize_css(css: str, start: int = 0) -> Dict[str, Any]: # Do basic validation on the supported color functions tokens['end'] = m.end() if func_name == 'color' and not validate_color(tokens): - return {} + return {} # pragma: no cover elif func_name.startswith('rgb'): tokens['id'] = 'srgb' @@ -439,12 +442,12 @@ def tokenize_css(css: str, start: int = 0) -> Dict[str, Any]: def parse_css( - cspace: 'Space', + cspace: Space, string: str, start: int = 0, fullmatch: bool = True, color: bool = False -) -> Optional[Tuple[Tuple[Vector, float], int]]: +) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" target = cspace.SERIALIZE diff --git a/lib/coloraide/css/serialize.py b/lib/coloraide/css/serialize.py index b7959531..e0032186 100644 --- a/lib/coloraide/css/serialize.py +++ b/lib/coloraide/css/serialize.py @@ -1,12 +1,13 @@ """String serialization.""" +from __future__ import annotations import re import math from .. import util from .. import algebra as alg from .color_names import to_name -from ..channels import FLG_PERCENT, FLG_OPT_PERCENT, FLG_ANGLE +from ..channels import FLG_ANGLE from ..types import Vector -from typing import Optional, Union, TYPE_CHECKING +from typing import TYPE_CHECKING, Sequence, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -21,9 +22,9 @@ def named_color( obj: 'Color', - alpha: Optional[bool], - fit: Union[str, bool] -) -> Optional[str]: + alpha: bool | None, + fit: str | bool | dict[str, Any] +) -> str | None: """Get the CSS color name.""" a = get_alpha(obj, alpha, False, False) @@ -32,90 +33,102 @@ def named_color( return to_name(get_coords(obj, fit, False, False) + [a]) -def named_color_function( +def color_function( obj: 'Color', - func: str, - alpha: Optional[bool], + func: str | None, + alpha: bool | None, precision: int, - fit: Union[str, bool], + fit: str | bool | dict[str, Any], none: bool, - percent: bool, + percent: bool | Sequence[bool], legacy: bool, scale: float ) -> str: """Translate to CSS function form `name(...)`.""" - # Create the function `name` or `namea` if old legacy form. + # Prepare coordinates to be serialized a = get_alpha(obj, alpha, none, legacy) - string = ['{}{}('.format(func, 'a' if legacy and a is not None else EMPTY)] - - # Iterate the coordinates formatting them for percent, not percent, and even scaling them (sRGB). coords = get_coords(obj, fit, none, legacy) - channels = obj._space.CHANNELS + if a is not None: + coords.append(a) + + # `color` should include the color space serialized name. + if func is None: + string = ['color({} '.format(obj._space._serialize()[0])] + # Create the function `name` or `namea` if old legacy form. + else: + string = ['{}{}('.format(func, 'a' if legacy and a is not None else EMPTY)] + + # Get channel object and calculate length and the alpha index (last) + channels = obj._space.channels + l = len(channels) + last = l - 1 + + # Ensure percent is configured + # - `True` assumes all but alpha are attempted to be formatted as percents. + # - A list of booleans will attempt formatting the associated channel as percent, + # anything not specified is assumed `False`. + if isinstance(percent, bool): + plist = obj._space._percents if percent else [] + else: + diff = l - len(percent) + plist = list(percent) + ([False] * diff) if diff > 0 else list(percent) + + # Iterate the coordinates formatting them by scaling the values, formatting for percent, etc. for idx, value in enumerate(coords): - channel = channels[idx] - use_percent = channel.flags & FLG_PERCENT or (percent and channel.flags & FLG_OPT_PERCENT) - is_angle = channel.flags & FLG_ANGLE - if not use_percent and not is_angle: - value *= scale - if idx != 0: + is_last = idx == last + if is_last: + string.append(COMMA if legacy else SLASH) + elif idx != 0: string.append(COMMA if legacy else SPACE) + channel = channels[idx] + + if not (channel.flags & FLG_ANGLE) and plist and plist[idx]: + span, offset = channel.span, channel.offset + else: + span = offset = 0.0 + if not channel.flags & FLG_ANGLE and not is_last: + value *= scale + string.append( util.fmt_float( value, precision, - channel.span if use_percent else 0.0, - channel.offset if use_percent else 0.0 + span, + offset ) ) - # Add alpha if needed - if a is not None: - string.append('{}{})'.format(COMMA if legacy else SLASH, util.fmt_float(a, max(precision, util.DEF_PREC)))) - else: - string.append(')') + string.append(')') return EMPTY.join(string) -def color_function( - obj: 'Color', - alpha: Optional[bool], - precision: int, - fit: Union[str, bool], - none: bool -) -> str: - """Color format.""" - - # Export in the `color(space ...)` format - coords = get_coords(obj, fit, none, False) - a = get_alpha(obj, alpha, none, False) - return ( - 'color({} {}{})'.format( - obj._space._serialize()[0], - SPACE.join([util.fmt_float(coord, precision) for coord in coords]), - SLASH + util.fmt_float(a, max(precision, util.DEF_PREC)) if a is not None else EMPTY - ) - ) - - def get_coords( obj: 'Color', - fit: Union[str, bool], + fit: bool | str | dict[str, Any], none: bool, legacy: bool ) -> Vector: """Get the coordinates.""" - color = (obj.fit(method=None if not isinstance(fit, str) else fit) if fit else obj) + if fit: + if fit is True: + color = obj.fit() + elif isinstance(fit, str): + color = obj.fit(method=fit) + else: + color = obj.fit(**fit) + else: + color = obj return color.coords(nans=False if legacy or not none else True) def get_alpha( obj: 'Color', - alpha: Optional[bool], + alpha: bool | None, none: bool, legacy: bool -) -> Optional[float]: +) -> float | None: """Get the alpha if required.""" a = obj.alpha(nans=False if not none or legacy else True) @@ -125,8 +138,8 @@ def get_alpha( def hexadecimal( obj: 'Color', - alpha: Optional[bool] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + fit: str | bool | dict[str, Any] = True, upper: bool = False, compress: bool = False ) -> str: @@ -163,11 +176,11 @@ def serialize_css( obj: 'Color', func: str = '', color: bool = False, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, hexa: bool = False, upper: bool = False, compress: bool = False, @@ -182,7 +195,7 @@ def serialize_css( # Color format if color: - return color_function(obj, alpha, precision, fit, none) + return color_function(obj, None, alpha, precision, fit, none, percent, False, 1.0) # CSS color names if name: @@ -196,6 +209,6 @@ def serialize_css( # Normal CSS named function format if func: - return named_color_function(obj, func, alpha, precision, fit, none, percent, legacy, scale) + return color_function(obj, func, alpha, precision, fit, none, percent, legacy, scale) raise RuntimeError('Could not identify a CSS format to serialize to') # pragma: no cover diff --git a/lib/coloraide/deprecate.py b/lib/coloraide/deprecate.py index 278232ae..12b04d9e 100644 --- a/lib/coloraide/deprecate.py +++ b/lib/coloraide/deprecate.py @@ -1,10 +1,11 @@ """Deprecation functions.""" +from __future__ import annotations import warnings from functools import wraps from typing import Any, Callable -def deprecated(message: str, stacklevel: int = 2) -> Callable[..., Any]: # pragma: no cover +def deprecated(message: str, stacklevel: int = 2) -> Callable[..., Any]: """ Raise a `DeprecationWarning` when wrapped function/method is called. @@ -28,7 +29,7 @@ def _deprecated_func(*args: Any, **kwargs: Any) -> Any: return _wrapper -def warn_deprecated(message: str, stacklevel: int = 2) -> None: # pragma: no cover +def warn_deprecated(message: str, stacklevel: int = 2) -> None: """Warn deprecated.""" warnings.warn( diff --git a/lib/coloraide/distance/__init__.py b/lib/coloraide/distance/__init__.py index 1962ae83..89f4da0e 100644 --- a/lib/coloraide/distance/__init__.py +++ b/lib/coloraide/distance/__init__.py @@ -1,15 +1,16 @@ """Distance and Delta E.""" -from abc import ABCMeta, abstractmethod +from __future__ import annotations import math from .. import algebra as alg +from abc import ABCMeta, abstractmethod from ..types import ColorInput, Plugin -from typing import TYPE_CHECKING, Any, Sequence, Optional +from typing import TYPE_CHECKING, Any, Sequence if TYPE_CHECKING: # pragma: no cover from ..color import Color -def closest(color: 'Color', colors: Sequence[ColorInput], method: Optional[str] = None, **kwargs: Any) -> 'Color': +def closest(color: Color, colors: Sequence[ColorInput], method: str | None = None, **kwargs: Any) -> Color: """Get the closest color.""" if method is None: @@ -19,7 +20,7 @@ def closest(color: 'Color', colors: Sequence[ColorInput], method: Optional[str] if not algorithm: raise ValueError("'{}' is not currently a supported distancing algorithm.".format(method)) - lowest = alg.inf + lowest = math.inf closest = None for c in colors: color2 = color._handle_color_input(c) @@ -34,15 +35,29 @@ def closest(color: 'Color', colors: Sequence[ColorInput], method: Optional[str] return closest -def distance_euclidean(color: 'Color', sample: 'Color', space: str = "lab-d65") -> float: +def distance_euclidean(color: Color, sample: Color, space: str = "lab-d65") -> float: """ Euclidean distance. https://en.wikipedia.org/wiki/Euclidean_distance """ - coords1 = color.convert(space, norm=False).coords(nans=False) - coords2 = sample.convert(space, norm=False).coords(nans=False) + # convert to the specified space + c1 = color.convert(space, norm=False) + c2 = sample.convert(space, norm=False) + coords1 = c1.coords(nans=False) + coords2 = c2.coords(nans=False) + + # Convert polar coordinate into rectangular coordinates + if c1._space.is_polar(): + hi = c1._space.hue_index() # type: ignore[attr-defined] + ri = c1._space.radial_index() # type: ignore[attr-defined] + a, b = alg.polar_to_rect(coords1[ri], coords1[hi]) + coords1[hi] = a + coords1[ri] = b + a, b = alg.polar_to_rect(coords2[ri], coords2[hi]) + coords2[hi] = a + coords2[ri] = b return math.sqrt(sum((x - y) ** 2.0 for x, y in zip(coords1, coords2))) @@ -53,5 +68,5 @@ class DeltaE(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def distance(self, color: 'Color', sample: 'Color', **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: """Get distance between color and sample.""" diff --git a/lib/coloraide/distance/delta_e_2000.py b/lib/coloraide/distance/delta_e_2000.py index 440d8e92..71c3edd6 100644 --- a/lib/coloraide/distance/delta_e_2000.py +++ b/lib/coloraide/distance/delta_e_2000.py @@ -1,7 +1,8 @@ """Delta E 2000.""" +from __future__ import annotations import math -from .. import algebra as alg from ..distance import DeltaE +from ..spaces.lab import CIELab from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover @@ -12,26 +13,30 @@ class DE2000(DeltaE): """Delta E 2000 class.""" NAME = "2000" - - # CSS uses D50 because that is the only Lab in the spec, - # but most implementation use D65. Typically D50 is the - # choice for reflective (i.e. Paper) or transmissive readings, - # while displays would typically use a measured white reference, - # or D65. If a CSS compliant variant is desired, simply subclass - # and set `LAB` to `lab-d50`. If the intent is not to override, - # then set `NAME` to something like `NAME="2000-D50"`. - LAB = "lab-d65" - G_CONST = 25 ** 7 - @classmethod - def distance( - cls, - color: 'Color', - sample: 'Color', + def __init__( + self, kl: float = 1, kc: float = 1, kh: float = 1, + space: str = 'lab-d65' + ): + """Initialize.""" + + self.kl = kl + self.kc = kc + self.kh = kh + self.space = space + + def distance( + self, + color: Color, + sample: Color, + kl: float | None = None, + kc: float | None = None, + kh: float | None = None, + space: str | None = None, **kwargs: Any ) -> float: """ @@ -43,8 +48,22 @@ def distance( http://www2.ece.rochester.edu/~gsharma/ciede2000/ciede2000noteCRNA.pdf """ - l1, a1, b1 = color.convert(cls.LAB).coords(nans=False) - l2, a2, b2 = sample.convert(cls.LAB).coords(nans=False) + if kl is None: + kl = self.kl + + if kc is None: + kc = self.kc + + if kh is None: + kh = self.kh + + if space is None: + space = self.space + if not isinstance(color.CS_MAP[space], CIELab): + raise ValueError("Distance color space must be a CIE Lab color space.") + + l1, a1, b1 = color.convert(space).coords(nans=False) + l2, a2, b2 = sample.convert(space).coords(nans=False) # Equation (2) c1 = math.sqrt(a1 ** 2 + b1 ** 2) @@ -55,7 +74,7 @@ def distance( # Equation (4) c7 = cm ** 7 - g = 0.5 * (1 - math.sqrt(c7 / (c7 + cls.G_CONST))) + g = 0.5 * (1 - math.sqrt(c7 / (c7 + self.G_CONST))) # Equation (5) ap1 = (1 + g) * a1 @@ -68,8 +87,8 @@ def distance( # Equation (7) hp1 = 0 if (ap1 == 0 and b1 == 0) else math.atan2(b1, ap1) hp2 = 0 if (ap2 == 0 and b2 == 0) else math.atan2(b2, ap2) - hp1 = math.degrees(hp1 + alg.tau if hp1 < 0.0 else hp1) - hp2 = math.degrees(hp2 + alg.tau if hp2 < 0.0 else hp2) + hp1 = math.degrees(hp1 + math.tau if hp1 < 0.0 else hp1) + hp2 = math.degrees(hp2 + math.tau if hp2 < 0.0 else hp2) # Equation (8) dl = l1 - l2 @@ -124,7 +143,7 @@ def distance( # Equation (17) cpm7 = cpm ** 7 - rc = 2 * math.sqrt(cpm7 / (cpm7 + cls.G_CONST)) + rc = 2 * math.sqrt(cpm7 / (cpm7 + self.G_CONST)) # Equation (18) l_temp = (lpm - 50) ** 2 diff --git a/lib/coloraide/distance/delta_e_76.py b/lib/coloraide/distance/delta_e_76.py index c818050c..140559fd 100644 --- a/lib/coloraide/distance/delta_e_76.py +++ b/lib/coloraide/distance/delta_e_76.py @@ -1,7 +1,8 @@ """Delta E 76.""" +from __future__ import annotations from ..distance import DeltaE, distance_euclidean from typing import TYPE_CHECKING, Any - +from ..spaces.lab import CIELab if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -10,9 +11,19 @@ class DE76(DeltaE): """Delta E 76 class.""" NAME = "76" - SPACE = "lab-d65" - def distance(self, color: 'Color', sample: 'Color', **kwargs: Any) -> float: + def __init__(self, space: str = 'lab-d65'): + """Initialize.""" + + self.space = space + + def distance( + self, + color: Color, + sample: Color, + space: str | None = None, + **kwargs: Any + ) -> float: """ Delta E 1976 color distance formula. @@ -21,5 +32,10 @@ def distance(self, color: 'Color', sample: 'Color', **kwargs: Any) -> float: Basically this is Euclidean distance in the Lab space. """ + if space is None: + space = self.space + if not isinstance(color.CS_MAP[space], CIELab): + raise ValueError("Distance color space must be a CIE Lab color space.") + # Equation (1) - return distance_euclidean(color, sample, space=self.SPACE) + return distance_euclidean(color, sample, space=space) diff --git a/lib/coloraide/distance/delta_e_94.py b/lib/coloraide/distance/delta_e_94.py index 9aa3d49e..8a71e5a9 100644 --- a/lib/coloraide/distance/delta_e_94.py +++ b/lib/coloraide/distance/delta_e_94.py @@ -1,8 +1,9 @@ """Delta E 94.""" +from __future__ import annotations from ..distance import DeltaE +from ..spaces.lab import CIELab import math -from .. import algebra as alg -from typing import TYPE_CHECKING, Optional, Any +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -17,21 +18,24 @@ def __init__( self, kl: float = 1, k1: float = 0.045, - k2: float = 0.015 + k2: float = 0.015, + space: str = 'lab-d65' ): """Initialize.""" self.kl = kl self.k1 = k1 self.k2 = k2 + self.space = space def distance( self, - color: 'Color', - sample: 'Color', - kl: Optional[float] = None, - k1: Optional[float] = None, - k2: Optional[float] = None, + color: Color, + sample: Color, + kl: float | None = None, + k1: float | None = None, + k2: float | None = None, + space: str | None = None, **kwargs: Any ) -> float: """ @@ -49,8 +53,13 @@ def distance( if k2 is None: k2 = self.k2 - l1, a1, b1 = color.convert("lab").coords(nans=False) - l2, a2, b2 = sample.convert("lab").coords(nans=False) + if space is None: + space = self.space + if not isinstance(color.CS_MAP[space], CIELab): + raise ValueError("Distance color space must be a CIE Lab color space.") + + l1, a1, b1 = color.convert(space).coords(nans=False) + l2, a2, b2 = sample.convert(space).coords(nans=False) # Equation (5) c1 = math.sqrt(a1 ** 2 + b1 ** 2) diff --git a/lib/coloraide/distance/delta_e_99o.py b/lib/coloraide/distance/delta_e_99o.py index a7ddeb4f..18a9a5ab 100644 --- a/lib/coloraide/distance/delta_e_99o.py +++ b/lib/coloraide/distance/delta_e_99o.py @@ -3,11 +3,20 @@ https://de.wikipedia.org/wiki/DIN99-Farbraum """ -from .delta_e_76 import DE76 +from __future__ import annotations +from ..distance import DeltaE, distance_euclidean +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: # pragma: no cover + from ..color import Color -class DE99o(DE76): + +class DE99o(DeltaE): """Delta E 99o class.""" - NAME = "99o" - SPACE = "din99o" + NAME = '99o' + + def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: + """Get delta E 99o.""" + + return distance_euclidean(color, sample, space='din99o') diff --git a/lib/coloraide/distance/delta_e_cam16.py b/lib/coloraide/distance/delta_e_cam16.py index 163a70bd..a80f046a 100644 --- a/lib/coloraide/distance/delta_e_cam16.py +++ b/lib/coloraide/distance/delta_e_cam16.py @@ -3,25 +3,51 @@ https://www.ncbi.nlm.nih.gov/pmc/articles/PMC9698626/pdf/sensors-22-08869.pdf """ +from __future__ import annotations import math from ..distance import DeltaE +from ..deprecate import warn_deprecated from typing import Any, TYPE_CHECKING -from ..spaces.cam16_ucs import COEFFICENTS +from ..spaces.cam16_ucs import COEFFICENTS, CAM16UCS if TYPE_CHECKING: # pragma: no cover from ..color import Color +WARN_MSG = ( + "The 'model' parameter is now deprecated, please specify the CAM16 UCS/LCD/SCD space name via 'space' instead" +) + + class DECAM16(DeltaE): """Delta E CAM16 class.""" NAME = "cam16" - def distance(self, color: 'Color', sample: 'Color', model: str = 'ucs', **kwargs: Any) -> float: - """Delta E z color distance formula.""" + def distance( + self, + color: Color, + sample: Color, + space: str = "cam16-ucs", + model: str | None = None, + **kwargs: Any + ) -> float: + """Delta E CAM16 color distance formula.""" + + # Legacy approach to specifying CAM16 approach + if model is not None: # pragma: no cover + warn_deprecated(WARN_MSG) + space = 'cam16-{}'.format(model) + kl = COEFFICENTS[model][0] + + # Normal approach to specifying CAM16 target space + else: + cs = color.CS_MAP[space] + if not isinstance(color.CS_MAP[space], CAM16UCS): + raise ValueError("Distance color space must be derived from CAM16UCS.") + model = cs.MODEL # type: ignore[attr-defined] + kl = COEFFICENTS[model][0] - space = 'cam16-{}'.format(model) - kl = COEFFICENTS[model][0] j1, a1, b1 = color.convert(space).coords(nans=False) j2, a2, b2 = sample.convert(space).coords(nans=False) diff --git a/lib/coloraide/distance/delta_e_cmc.py b/lib/coloraide/distance/delta_e_cmc.py index 19968c6d..c6dfe211 100644 --- a/lib/coloraide/distance/delta_e_cmc.py +++ b/lib/coloraide/distance/delta_e_cmc.py @@ -1,7 +1,9 @@ """Delta E CMC.""" +from __future__ import annotations from ..distance import DeltaE +from ..spaces.lab import CIELab import math -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -15,19 +17,22 @@ class DECMC(DeltaE): def __init__( self, l: float = 2, - c: float = 1 + c: float = 1, + space: str = 'lab-d65' ): """Initialize.""" self.l = l self.c = c + self.space = space def distance( self, - color: 'Color', - sample: 'Color', - l: Optional[float] = None, - c: Optional[float] = None, + color: Color, + sample: Color, + l: float | None = None, + c: float | None = None, + space: str | None = None, **kwargs: Any ) -> float: """ @@ -42,8 +47,13 @@ def distance( if c is None: c = self.c - l1, a1, b1 = color.convert("lab").coords(nans=False) - l2, a2, b2 = sample.convert("lab").coords(nans=False) + if space is None: + space = self.space + if not isinstance(color.CS_MAP[space], CIELab): + raise ValueError("Distance color space must be a CIE Lab color space.") + + l1, a1, b1 = color.convert(space).coords(nans=False) + l2, a2, b2 = sample.convert(space).coords(nans=False) # Equation (3) c1 = math.sqrt(a1 ** 2 + b1 ** 2) diff --git a/lib/coloraide/distance/delta_e_hct.py b/lib/coloraide/distance/delta_e_hct.py index bf2879a0..4ce73c02 100644 --- a/lib/coloraide/distance/delta_e_hct.py +++ b/lib/coloraide/distance/delta_e_hct.py @@ -1,8 +1,9 @@ """Delta E CAM16.""" +from __future__ import annotations import math from ..distance import DeltaE from ..spaces.cam16_ucs import COEFFICENTS -from ..types import Vector +from ..types import VectorLike from typing import Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover @@ -11,15 +12,21 @@ COEFF2 = COEFFICENTS['ucs'][2] -def convert_ucs_ab(c: float, h: float) -> Vector: - """Convert HCT chroma and hue (CAM16 JMh colorfulness and hue) to UCS a and b.""" +def convert_ucs_ab(color: Color) -> VectorLike: + """Convert HCT chroma and hue (CAM16 JMh colorfulness and hue) using UCS logic for a and b.""" + env = color._space.ENV # type: ignore[attr-defined] + h, c, t = color.coords() + + # Only in extreme cases (far outside the visible spectrum) + # can the input value for log become negative. + # Avoid domain error by forcing zero. + M = math.log(max(1 + COEFF2 * c * env.fl_root, 1.0)) / COEFF2 hrad = math.radians(h) - M = math.log(1 + COEFF2 * c) / COEFF2 a = M * math.cos(hrad) b = M * math.sin(hrad) - return [a, b] + return t, a, b class DEHCT(DeltaE): @@ -27,14 +34,15 @@ class DEHCT(DeltaE): NAME = "hct" - def distance(self, color: 'Color', sample: 'Color', **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: """Delta E HCT color distance formula.""" - h1, c1, t1 = color.convert('hct', norm=False).coords(nans=False) - h2, c2, t2 = sample.convert('hct', norm=False).coords(nans=False) - - a1, b1 = convert_ucs_ab(c1, h1) - a2, b2 = convert_ucs_ab(c2, h2) + t1, a1, b1 = convert_ucs_ab( + color.convert('hct', norm=False) if color.space() != 'hct' else color.clone().normalize(nans=False) + ) + t2, a2, b2 = convert_ucs_ab( + sample.convert('hct', norm=False) if sample.space() != 'hct' else sample.clone().normalize(nans=False) + ) # Use simple euclidean distance return math.sqrt((t1 - t2) ** 2 + (a1 - a2) ** 2 + (b1 - b2) ** 2) diff --git a/lib/coloraide/distance/delta_e_hyab.py b/lib/coloraide/distance/delta_e_hyab.py index 5a7c4400..551b0c03 100644 --- a/lib/coloraide/distance/delta_e_hyab.py +++ b/lib/coloraide/distance/delta_e_hyab.py @@ -1,8 +1,9 @@ """HyAB distance.""" +from __future__ import annotations from ..distance import DeltaE import math from ..spaces import Labish -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -18,7 +19,7 @@ def __init__(self, space: str = "lab-d65") -> None: self.space = space - def distance(self, color: 'Color', sample: 'Color', space: Optional[str] = None, **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, space: str | None = None, **kwargs: Any) -> float: """ HyAB distance for Lab-ish spaces. diff --git a/lib/coloraide/distance/delta_e_itp.py b/lib/coloraide/distance/delta_e_itp.py index 741d067f..26c3f2b6 100644 --- a/lib/coloraide/distance/delta_e_itp.py +++ b/lib/coloraide/distance/delta_e_itp.py @@ -3,9 +3,10 @@ https://kb.portrait.com/help/ictcp-color-difference-metric """ +from __future__ import annotations from ..distance import DeltaE import math -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -21,7 +22,7 @@ def __init__(self, scalar: float = 720) -> None: self.scalar = scalar - def distance(self, color: 'Color', sample: 'Color', scalar: Optional[float] = None, **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, scalar: float | None = None, **kwargs: Any) -> float: """Delta E ITP color distance formula.""" if scalar is None: diff --git a/lib/coloraide/distance/delta_e_ok.py b/lib/coloraide/distance/delta_e_ok.py index 9186d422..71664004 100644 --- a/lib/coloraide/distance/delta_e_ok.py +++ b/lib/coloraide/distance/delta_e_ok.py @@ -1,23 +1,23 @@ """Delta E OK.""" -from .delta_e_76 import DE76 -from typing import TYPE_CHECKING, Any, Optional +from __future__ import annotations +from ..distance import DeltaE, distance_euclidean +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color -class DEOK(DE76): - """Delta E OK class.""" +class DEOK(DeltaE): + """Delta E 99o class.""" - NAME = "ok" - SPACE = "oklab" + NAME = 'ok' def __init__(self, scalar: float = 1) -> None: """Initialize.""" self.scalar = scalar - def distance(self, color: 'Color', sample: 'Color', scalar: Optional[float] = None, **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, scalar: float | None = None, **kwargs: Any) -> float: """ Delta E OK color distance formula. @@ -27,5 +27,4 @@ def distance(self, color: 'Color', sample: 'Color', scalar: Optional[float] = No if scalar is None: scalar = self.scalar - # Equation (1) - return scalar * super().distance(color, sample) + return scalar * distance_euclidean(color, sample, space='oklab') diff --git a/lib/coloraide/distance/delta_e_z.py b/lib/coloraide/distance/delta_e_z.py index a83ba10a..7f224048 100644 --- a/lib/coloraide/distance/delta_e_z.py +++ b/lib/coloraide/distance/delta_e_z.py @@ -3,6 +3,7 @@ https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272 """ +from __future__ import annotations from ..distance import DeltaE import math from typing import TYPE_CHECKING, Any @@ -16,7 +17,7 @@ class DEZ(DeltaE): NAME = "jz" - def distance(self, color: 'Color', sample: 'Color', **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: """Delta E z color distance formula.""" jz1, az1, bz1 = color.convert('jzazbz').coords(nans=False) diff --git a/lib/coloraide/easing.py b/lib/coloraide/easing.py index 871f0e11..11c4775d 100644 --- a/lib/coloraide/easing.py +++ b/lib/coloraide/easing.py @@ -38,9 +38,10 @@ This greatly simplifies things and makes it faster. """ +from __future__ import annotations import functools -from typing import Tuple, Callable -from . import algebra as alg +import math +from typing import Callable EPSILON = 1e-6 MAX_ITER = 8 @@ -74,7 +75,7 @@ def _solve_bezier(target: float, a: float, b: float, c: float) -> float: # Try Newtons method to see if we can find a suitable value x = 0.0 t = 0.5 - last = alg.nan + last = math.nan for _ in range(MAX_ITER): # See how close we are to the desired `x` x = _bezier(t, a, b, c) - target @@ -118,7 +119,7 @@ def _solve_bezier(target: float, a: float, b: float, c: float) -> float: return t # pragma: no cover -def _extrapolate(t: float, p1: Tuple[float, float], p2: Tuple[float, float]) -> float: +def _extrapolate(t: float, p1: tuple[float, float], p2: tuple[float, float]) -> float: """ Extrapolate. @@ -159,11 +160,11 @@ def _extrapolate(t: float, p1: Tuple[float, float], p2: Tuple[float, float]) -> def _calc_bezier( target: float, - a: Tuple[float, float], - b: Tuple[float, float], - c: Tuple[float, float], - p1: Tuple[float, float], - p2: Tuple[float, float] + a: tuple[float, float], + b: tuple[float, float], + c: tuple[float, float], + p1: tuple[float, float], + p2: tuple[float, float] ) -> float: """ Calculate the y value of the bezier curve with the given `x`. diff --git a/lib/coloraide/everything.py b/lib/coloraide/everything.py index 79310fd5..4d45250f 100644 --- a/lib/coloraide/everything.py +++ b/lib/coloraide/everything.py @@ -1,10 +1,6 @@ """Everything and the kitchen sink.""" +from __future__ import annotations from .color import Color as Base -from .spaces.rec2100_pq import Rec2100PQ -from .spaces.rec2100_hlg import Rec2100HLG -from .spaces.jzazbz import Jzazbz -from .spaces.jzczhz import JzCzhz -from .spaces.ictcp import ICtCp from .spaces.din99o import DIN99o from .spaces.lch99o import LCh99o from .spaces.luv import Luv @@ -28,17 +24,15 @@ from .spaces.acescg import ACEScg from .spaces.acescc import ACEScc from .spaces.acescct import ACEScct -from .spaces.cam16 import CAM16 from .spaces.cam16_jmh import CAM16JMh from .spaces.cam16_ucs import CAM16UCS, CAM16LCD, CAM16SCD +from .spaces.zcam_jmh import ZCAMJMh from .spaces.hct import HCT from .spaces.ucs import UCS from .spaces.rec709 import Rec709 from .spaces.ryb import RYB, RYBBiased from .spaces.cubehelix import Cubehelix -from .distance.delta_e_itp import DEITP from .distance.delta_e_99o import DE99o -from .distance.delta_e_z import DEZ from .distance.delta_e_cam16 import DECAM16 from .distance.delta_e_hct import DEHCT from .gamut.fit_hct_chroma import HCTChroma @@ -60,11 +54,6 @@ class ColorAll(Base): [ # Spaces Rec709(), - Rec2100PQ(), - Rec2100HLG(), - Jzazbz(), - JzCzhz(), - ICtCp(), DIN99o(), LCh99o(), Luv(), @@ -88,7 +77,6 @@ class ColorAll(Base): ACEScg(), ACEScc(), ACEScct(), - CAM16(), CAM16JMh(), CAM16UCS(), CAM16SCD(), @@ -98,11 +86,10 @@ class ColorAll(Base): RYB(), RYBBiased(), Cubehelix(), + ZCAMJMh(), # Delta E - DEITP(), DE99o(), - DEZ(), DECAM16(), DEHCT(), diff --git a/lib/coloraide/filters/__init__.py b/lib/coloraide/filters/__init__.py index 601583ea..48397f19 100644 --- a/lib/coloraide/filters/__init__.py +++ b/lib/coloraide/filters/__init__.py @@ -1,7 +1,8 @@ """Provides a plugin system for filtering colors.""" +from __future__ import annotations from abc import ABCMeta, abstractmethod from ..types import Plugin -from typing import Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -12,22 +13,22 @@ class Filter(Plugin, metaclass=ABCMeta): NAME = '' DEFAULT_SPACE = 'srgb-linear' - ALLOWED_SPACES = ('srgb-linear',) # type: Tuple[str, ...] + ALLOWED_SPACES = ('srgb-linear',) # type: tuple[str, ...] @abstractmethod - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Filter the given color.""" def filters( - color: 'Color', + color: Color, name: str, - amount: Optional[float] = None, - space: Optional[str] = None, - out_space: Optional[str] = None, + amount: float | None = None, + space: str | None = None, + out_space: str | None = None, in_place: bool = False, **kwargs: Any -) -> 'Color': +) -> Color: """Filter.""" f = color.FILTER_MAP.get(name) diff --git a/lib/coloraide/filters/cvd.py b/lib/coloraide/filters/cvd.py index 3ea36555..abc8a55e 100644 --- a/lib/coloraide/filters/cvd.py +++ b/lib/coloraide/filters/cvd.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- """Color vision deficiency.""" +from __future__ import annotations from .. import algebra as alg from ..filters import Filter from ..types import Vector, Matrix -from typing import Any, Optional, Dict, Tuple, Callable, TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -32,7 +33,7 @@ [0.0, -0.4913704825845725, 69.55210571887513] ], [0.0, 0.016813516536000002, -0.344781556122] -) # type: Tuple[Matrix, Matrix, Vector] +) # type: tuple[Matrix, Matrix, Vector] BRETTEL_DEUTAN = ( [ @@ -46,7 +47,7 @@ [-0.2250635463221503, 0.0, 68.24609126806706] ], [-0.016813516536000002, 0.0, 0.6551784438780001] -) # type: Tuple[Matrix, Matrix, Vector] +) # type: tuple[Matrix, Matrix, Vector] BRETTEL_TRITAN = ( [ @@ -60,7 +61,7 @@ [-0.2150297288038942, 3.3090019545637928, 0.0] ], [0.344781556122, -0.6551784438780001, 0.0] -) # type: Tuple[Matrix, Matrix, Vector] +) # type: tuple[Matrix, Matrix, Vector] VIENOT_PROTAN = [ [0.11238276122216405, 0.8876172387778362, 5.551115123125783e-17], @@ -92,7 +93,7 @@ 8: [[0.259411, 0.923008, -0.182420], [0.110296, 0.804340, 0.085364], [-0.006276, -0.034346, 1.040622]], 9: [[0.203876, 0.990338, -0.194214], [0.112975, 0.794542, 0.092483], [-0.005222, -0.041043, 1.046265]], 10: [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]] -} # type: Dict[int, Matrix] +} # type: dict[int, Matrix] MACHADO_DEUTAN = { 0: [[1.000000, 0.000000, -0.000000], [0.000000, 1.000000, 0.000000], [-0.000000, -0.000000, 1.000000]], @@ -107,7 +108,7 @@ 9: [[0.392952, 0.823610, -0.216562], [0.263559, 0.690210, 0.046232], [-0.011910, 0.040281, 0.971630]], 10: [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.011820, 0.042940, 0.968881]], -} # type: Dict[int, Matrix] +} # type: dict[int, Matrix] MACHADO_TRITAN = { 0: [[1.000000, 0.000000, -0.000000], [0.000000, 1.000000, 0.000000], [-0.000000, -0.000000, 1.000000]], @@ -121,10 +122,10 @@ 8: [[1.257728, -0.139648, -0.118081], [-0.078003, 0.975409, 0.102594], [-0.003316, 0.501214, 0.502102]], 9: [[1.278864, -0.125333, -0.153531], [-0.084748, 0.957674, 0.127074], [-0.000989, 0.601151, 0.399838]], 10: [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.303900]], -} # type: Dict[int, Matrix] +} # type: dict[int, Matrix] -def brettel(color: 'Color', severity: float, wings: Tuple[Matrix, Matrix, Vector]) -> None: +def brettel(color: Color, severity: float, wings: tuple[Matrix, Matrix, Vector]) -> None: """ Calculate color blindness using Brettel 1997. @@ -136,11 +137,11 @@ def brettel(color: 'Color', severity: float, wings: Tuple[Matrix, Matrix, Vector w1, w2, sep = wings # Convert to LMS - lms_c = alg.dot(LRGB_TO_LMS, color[:-1], dims=alg.D2_D1) + lms_c = alg.matmul(LRGB_TO_LMS, color[:-1], dims=alg.D2_D1) # Apply appropriate wing filter based on which side of the separator we are on. # Tritanopia filter and LMS to sRGB conversion are included in the same matrix. - coords = alg.dot(w2 if alg.dot(lms_c, sep) > 0 else w1, lms_c, dims=alg.D2_D1) + coords = alg.matmul(w2 if alg.matmul(lms_c, sep) > 0 else w1, lms_c, dims=alg.D2_D1) if severity < 1: color[:-1] = [alg.lerp(a, b, severity) for a, b in zip(color[:-1], coords)] @@ -148,7 +149,7 @@ def brettel(color: 'Color', severity: float, wings: Tuple[Matrix, Matrix, Vector color[:-1] = coords -def vienot(color: 'Color', severity: float, transform: Matrix) -> None: +def vienot(color: Color, severity: float, transform: Matrix) -> None: """ Calculate color blindness using the Viénot, Brettel, and Mollon 1999 approach, best for protanopia and deuteranopia. @@ -164,14 +165,14 @@ def vienot(color: 'Color', severity: float, transform: Matrix) -> None: then we interpolate against the original color. """ - coords = alg.dot(transform, color[:-1], dims=alg.D2_D1) + coords = alg.matmul(transform, color[:-1], dims=alg.D2_D1) if severity < 1: color[:-1] = [alg.lerp(c1, c2, severity) for c1, c2 in zip(color[:-1], coords)] else: color[:-1] = coords -def machado(color: 'Color', severity: float, matrices: Dict[int, Matrix]) -> None: +def machado(color: Color, severity: float, matrices: dict[int, Matrix]) -> None: """ Machado approach to protanopia, deuteranopia, and tritanopia. @@ -187,7 +188,7 @@ def machado(color: 'Color', severity: float, matrices: Dict[int, Matrix]) -> Non # Filter the color according to the severity m1 = matrices[severity1] - coords = alg.dot(m1, color[:-1], dims=alg.D2_D1) + coords = alg.matmul(m1, color[:-1], dims=alg.D2_D1) # If severity was not exact, and it also isn't max severity, # let's calculate the next most severity and interpolate @@ -204,7 +205,7 @@ def machado(color: 'Color', severity: float, matrices: Dict[int, Matrix]) -> Non # but it ends up being faster just modifying the color on both the high # and low matrix and interpolating the color than interpolating the matrix # and then applying it to the color. The results are identical as well. - coords2 = alg.dot(m2, color[:-1], dims=alg.D2_D1) + coords2 = alg.matmul(m2, color[:-1], dims=alg.D2_D1) coords = [alg.lerp(c1, c2, weight) for c1, c2 in zip(coords, coords2)] # Return the altered color @@ -222,23 +223,23 @@ class Protan(Filter): VIENOT = VIENOT_PROTAN MACHADO = MACHADO_PROTAN - def __init__(self, severe: str = 'vienot', anomalous: str = 'machado') -> None: + def __init__(self, severe: str = 'vienot', anomalous: str = 'machado', **kwargs: Any) -> None: """Initialize.""" self.severe = severe self.anomalous = anomalous - def brettel(self, color: 'Color', severity: float) -> None: + def brettel(self, color: Color, severity: float) -> None: """Tritanopia vision deficiency using Brettel method.""" brettel(color, severity, self.BRETTEL) - def vienot(self, color: 'Color', severity: float) -> None: + def vienot(self, color: Color, severity: float) -> None: """Tritanopia vision deficiency using Viénot method.""" vienot(color, severity, self.VIENOT) - def machado(self, color: 'Color', severity: float) -> None: + def machado(self, color: Color, severity: float) -> None: """Tritanopia vision deficiency using Machado method.""" machado(color, severity, self.MACHADO) @@ -255,17 +256,17 @@ def select_filter(self, method: str) -> Callable[..., None]: else: raise ValueError("Unrecognized CVD filter method '{}'".format(method)) - def get_best_filter(self, method: Optional[str], max_severity: bool) -> Callable[..., None]: + def get_best_filter(self, method: str | None, max_severity: bool) -> Callable[..., None]: """Get the best filter based on the situation.""" if method is None: method = self.severe if max_severity else self.anomalous return self.select_filter(method) - def filter(self, color: 'Color', amount: Optional[float] = None, **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None = None, **kwargs: Any) -> None: # noqa: A003 """Filter the color.""" - method = kwargs.get('method') # type: Optional[str] + method = kwargs.get('method') # type: str | None amount = alg.clamp(1 if amount is None else amount, 0, 1) self.get_best_filter(method, amount == 1)(color, amount) diff --git a/lib/coloraide/filters/w3c_filter_effects.py b/lib/coloraide/filters/w3c_filter_effects.py index 927db031..8adc4deb 100644 --- a/lib/coloraide/filters/w3c_filter_effects.py +++ b/lib/coloraide/filters/w3c_filter_effects.py @@ -1,8 +1,9 @@ """Provide filters as described by the https://www.w3.org/TR/filter-effects-1/.""" +from __future__ import annotations import math from ..filters import Filter from .. import algebra as alg -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -24,7 +25,7 @@ class Sepia(Filter): NAME = 'sepia' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a sepia filter to the color.""" amount = 1 - alg.clamp(1 if amount is None else amount, 0, 1) @@ -35,7 +36,7 @@ def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None [0.272 - 0.272 * amount, 0.534 - 0.534 * amount, 0.131 + 0.869 * amount] ] - color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) class Grayscale(Filter): @@ -44,7 +45,7 @@ class Grayscale(Filter): NAME = 'grayscale' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a grayscale filter to the color.""" amount = 1 - alg.clamp(1 if amount is None else amount, 0, 1) @@ -55,7 +56,7 @@ def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None [0.2126 - 0.2126 * amount, 0.7152 - 0.7152 * amount, 0.0722 + 0.9278 * amount] ] - color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) class Saturate(Filter): @@ -64,7 +65,7 @@ class Saturate(Filter): NAME = 'saturate' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a saturation filter to the color.""" amount = alg.clamp(1 if amount is None else amount, 0) @@ -75,16 +76,16 @@ def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None [0.213 - 0.213 * amount, 0.715 - 0.715 * amount, 0.072 + 0.928 * amount] ] - color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) class Invert(Filter): - """Grayscale filter.""" + """Invert filter.""" NAME = 'invert' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply an invert filter.""" amount = alg.clamp(1 if amount is None else amount, 0, 1) @@ -98,7 +99,7 @@ class Opacity(Filter): NAME = 'opacity' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply an opacity filter.""" amount = alg.clamp(1 if amount is None else amount, 0, 1) @@ -111,7 +112,7 @@ class Brightness(Filter): NAME = 'brightness' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a brightness filter.""" amount = alg.clamp(1 if amount is None else amount, 0) @@ -125,7 +126,7 @@ class Contrast(Filter): NAME = 'contrast' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a contrast filter.""" amount = alg.clamp(1 if amount is None else amount, 0) @@ -139,7 +140,7 @@ class HueRotate(Filter): NAME = 'hue-rotate' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a hue rotation filter.""" rad = math.radians(0 if amount is None else amount) @@ -152,4 +153,4 @@ def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None [0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072] ] - color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) diff --git a/lib/coloraide/gamut/__init__.py b/lib/coloraide/gamut/__init__.py index 353b2040..270766cc 100644 --- a/lib/coloraide/gamut/__init__.py +++ b/lib/coloraide/gamut/__init__.py @@ -1,11 +1,10 @@ """Gamut handling.""" +from __future__ import annotations import math -from .. import algebra as alg from ..channels import FLG_ANGLE from abc import ABCMeta, abstractmethod from ..types import Plugin from typing import TYPE_CHECKING, Any -from .. import util from . import pointer if TYPE_CHECKING: # pragma: no cover @@ -13,34 +12,45 @@ __all__ = ('clip_channels', 'verify', 'Fit', 'pointer') -def clip_channels(color: 'Color', nans: bool = True) -> None: + +def clip_channels(color: Color, nans: bool = True) -> bool: """Clip channels.""" - for i, value in enumerate(color[:-1]): + clipped = False - chan = color._space.CHANNELS[i] + cs = color._space + for i, value in enumerate(cs.normalize(color[:-1])): - # Wrap the angle. Not technically out of gamut, but we will clean it up. - if chan.flags & FLG_ANGLE: - color[i] = util.constrain_hue(value) - continue + chan = cs.CHANNELS[i] - # Ignore undefined or unbounded channels - if not chan.bound or math.isnan(value): + # Ignore angles, undefined, or unbounded channels + if not chan.bound or math.isnan(value) or chan.flags & FLG_ANGLE: + color[i] = value continue # Fit value in bounds. - color[i] = alg.clamp(value, chan.low, chan.high) + if value < chan.low: + color[i] = chan.low + elif value > chan.high: + color[i] = chan.high + else: + color[i] = value + continue + + clipped = True + + return clipped -def verify(color: 'Color', tolerance: float) -> bool: +def verify(color: Color, tolerance: float) -> bool: """Verify the values are in bound.""" - for i, value in enumerate(color[:-1]): - chan = color._space.CHANNELS[i] + cs = color._space + for i, value in enumerate(cs.normalize(color[:-1])): + chan = cs.CHANNELS[i] # Ignore undefined channels, angles which wrap, and unbounded channels - if chan.flags & FLG_ANGLE or not chan.bound or math.isnan(value): + if not chan.bound or math.isnan(value) or chan.flags & FLG_ANGLE: continue a = chan.low @@ -58,5 +68,5 @@ class Fit(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def fit(self, color: 'Color', **kwargs: Any) -> None: + def fit(self, color: Color, space: str, **kwargs: Any) -> None: """Get coordinates of the new gamut mapped color.""" diff --git a/lib/coloraide/gamut/fit_hct_chroma.py b/lib/coloraide/gamut/fit_hct_chroma.py index b2470825..196531bd 100644 --- a/lib/coloraide/gamut/fit_hct_chroma.py +++ b/lib/coloraide/gamut/fit_hct_chroma.py @@ -1,4 +1,5 @@ """HCT gamut mapping.""" +from __future__ import annotations from ..gamut.fit_lch_chroma import LChChroma @@ -7,9 +8,10 @@ class HCTChroma(LChChroma): NAME = "hct-chroma" - EPSILON = 0.001 - LIMIT = 0.02 + EPSILON = 0.01 + LIMIT = 2.0 DE = "hct" + DE_OPTIONS = {} SPACE = "hct" MIN_LIGHTNESS = 0 MAX_LIGHTNESS = 100 diff --git a/lib/coloraide/gamut/fit_lch_chroma.py b/lib/coloraide/gamut/fit_lch_chroma.py index b19d37e8..e1a75f49 100644 --- a/lib/coloraide/gamut/fit_lch_chroma.py +++ b/lib/coloraide/gamut/fit_lch_chroma.py @@ -1,10 +1,12 @@ """Fit by compressing chroma in LCh.""" +from __future__ import annotations +import functools from ..gamut import Fit, clip_channels from ..cat import WHITES from .. import util import math from .. import algebra as alg -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -13,6 +15,13 @@ BLACK = [0, 0, 0] +@functools.lru_cache(maxsize=10) +def calc_epsilon(jnd: float) -> float: + """Calculate the epsilon to 2 degrees smaller than the specified JND.""" + + return float("1e{:d}".format(alg.order(jnd) - 2)) + + class LChChroma(Fit): """ LCh chroma gamut mapping class. @@ -31,30 +40,33 @@ class LChChroma(Fit): NAME = "lch-chroma" - EPSILON = 0.1 + EPSILON = 0.01 LIMIT = 2.0 DE = "2000" - DE_OPTIONS = {} # type: Dict[str, Any] + DE_OPTIONS = {'space': 'lab-d65'} # type: dict[str, Any] SPACE = "lch-d65" MIN_LIGHTNESS = 0 MAX_LIGHTNESS = 100 MIN_CONVERGENCE = 0.0001 - def fit(self, color: 'Color', **kwargs: Any) -> None: + def fit(self, color: Color, space: str, *, jnd: float | None = None, **kwargs: Any) -> None: """Gamut mapping via CIELCh chroma.""" - space = color.space() - mapcolor = color.convert(self.SPACE, norm=False) + orig = color.space() + mapcolor = color.convert(self.SPACE, norm=False) if orig != self.SPACE else color.clone().normalize(nans=False) + gamutcolor = color.convert(space, norm=False) if orig != space else color.clone().normalize(nans=False) l, c = mapcolor._space.indexes()[:2] # type: ignore[attr-defined] lightness = mapcolor[l] - sdr = color._space.DYNAMIC_RANGE == 'sdr' + sdr = gamutcolor._space.DYNAMIC_RANGE == 'sdr' + if jnd is None: + jnd = self.LIMIT + epsilon = self.EPSILON + else: + epsilon = calc_epsilon(jnd) # Return white or black if lightness is out of dynamic range for lightness. # Extreme light case only applies to SDR, but dark case applies to all ranges. - if ( - sdr and - (lightness >= self.MAX_LIGHTNESS or alg.isclose(lightness, self.MAX_LIGHTNESS, abs_tol=1e-6, dims=alg.SC)) - ): + if sdr and (lightness >= self.MAX_LIGHTNESS or math.isclose(lightness, self.MAX_LIGHTNESS, abs_tol=1e-6)): clip_channels(color.update('xyz-d65', WHITE, mapcolor[-1])) return elif lightness <= self.MIN_LIGHTNESS: @@ -64,10 +76,10 @@ def fit(self, color: 'Color', **kwargs: Any) -> None: # Set initial chroma boundaries low = 0.0 high = mapcolor[c] - clip_channels(color._hotswap(mapcolor.convert(space, norm=False))) + clip_channels(gamutcolor._hotswap(mapcolor.convert(space, norm=False))) # Adjust chroma if we are not under the JND yet. - if mapcolor.delta_e(color, method=self.DE, **self.DE_OPTIONS) >= self.LIMIT: + if mapcolor.delta_e(gamutcolor, method=self.DE, **self.DE_OPTIONS) >= jnd: # Perform "in gamut" checks until we know our lower bound is no longer in gamut. lower_in_gamut = True @@ -80,12 +92,12 @@ def fit(self, color: 'Color', **kwargs: Any) -> None: if lower_in_gamut and mapcolor.in_gamut(space, tolerance=0): low = mapcolor[c] else: - clip_channels(color._hotswap(mapcolor.convert(space, norm=False))) - de = mapcolor.delta_e(color, method=self.DE, **self.DE_OPTIONS) - if de < self.LIMIT: + clip_channels(gamutcolor._hotswap(mapcolor.convert(space, norm=False))) + de = mapcolor.delta_e(gamutcolor, method=self.DE, **self.DE_OPTIONS) + if de < jnd: # Kick out as soon as we are close enough to the JND. # Too far below and we may reduce chroma too aggressively. - if (self.LIMIT - de) < self.EPSILON: + if (jnd - de) < epsilon: break # Our lower bound is now out of gamut, so all future searches are @@ -97,4 +109,4 @@ def fit(self, color: 'Color', **kwargs: Any) -> None: else: # We are still outside the gamut and outside the JND high = mapcolor[c] - color.normalize() + color._hotswap(gamutcolor.convert(orig, norm=False)).normalize() diff --git a/lib/coloraide/gamut/fit_lch_raytrace.py b/lib/coloraide/gamut/fit_lch_raytrace.py new file mode 100644 index 00000000..28663009 --- /dev/null +++ b/lib/coloraide/gamut/fit_lch_raytrace.py @@ -0,0 +1,9 @@ +"""Gamut map using ray tracing.""" +from .fit_raytrace import RayTrace + + +class LChRayTrace(RayTrace): + """Apply gamut mapping using ray tracing.""" + + NAME = 'lch-raytrace' + PSPACE = "lch-d65" diff --git a/lib/coloraide/gamut/fit_oklch_chroma.py b/lib/coloraide/gamut/fit_oklch_chroma.py index 8f176495..be3c0ecf 100644 --- a/lib/coloraide/gamut/fit_oklch_chroma.py +++ b/lib/coloraide/gamut/fit_oklch_chroma.py @@ -1,4 +1,5 @@ """Fit by compressing chroma in OkLCh.""" +from __future__ import annotations from .fit_lch_chroma import LChChroma @@ -10,5 +11,6 @@ class OkLChChroma(LChChroma): EPSILON = 0.0001 LIMIT = 0.02 DE = "ok" + DE_OPTIONS = {} SPACE = "oklch" MAX_LIGHTNESS = 1 diff --git a/lib/coloraide/gamut/fit_oklch_raytrace.py b/lib/coloraide/gamut/fit_oklch_raytrace.py new file mode 100644 index 00000000..8575af18 --- /dev/null +++ b/lib/coloraide/gamut/fit_oklch_raytrace.py @@ -0,0 +1,9 @@ +"""Gamut map using ray tracing.""" +from .fit_raytrace import RayTrace + + +class OkLChRayTrace(RayTrace): + """Apply gamut mapping using ray tracing.""" + + NAME = 'oklch-raytrace' + PSPACE = "oklch" diff --git a/lib/coloraide/gamut/fit_raytrace.py b/lib/coloraide/gamut/fit_raytrace.py new file mode 100644 index 00000000..a556f250 --- /dev/null +++ b/lib/coloraide/gamut/fit_raytrace.py @@ -0,0 +1,269 @@ +""" +Gamut mapping by using ray tracing. + +This employs a faster approach than bisecting to reduce chroma. +""" +from __future__ import annotations +import math +from .. import algebra as alg +from ..gamut import Fit +from ..spaces import Space, RGBish, HSLish, HSVish, HWBish, Labish +from ..spaces.hsl import hsl_to_srgb, srgb_to_hsl +from ..spaces.hsv import hsv_to_srgb, srgb_to_hsv +from ..spaces.hwb import hwb_to_srgb, srgb_to_hwb +from ..spaces.srgb_linear import sRGBLinear +from ..deprecate import warn_deprecated +from ..types import Vector, VectorLike +from typing import TYPE_CHECKING, Callable, Any # noqa: F401 + +if TYPE_CHECKING: # pragma: no cover + from ..color import Color + + +def coerce_to_rgb(OrigColor: type[Color], cs: Space) -> tuple[type[Color], str]: + """ + Coerce an HSL, HSV, or HWB color space to RGB to allow us to ray trace the gamut. + + It is rare to have a color space that is bound to an RGB gamut that does not exist as an RGB + defined RGB space. HPLuv is one that is defined only as a cylindrical, HSL-like space. Okhsl + and Okhsv are another whose gamut is meant to target sRGB, but it is very fuzzy and has sRGB + colors not quite in gamut, and others that exceed the sRGB gamut. + + For gamut mapping, RGB cylindrical spaces can be coerced into an RGB form using traditional + HSL, HSV, or HWB approaches which is good enough. + """ + + if isinstance(cs, HSLish): + to_ = hsl_to_srgb # type: Callable[[Vector], Vector] + from_ = srgb_to_hsl # type: Callable[[Vector], Vector] + elif isinstance(cs, HSVish): + to_ = hsv_to_srgb + from_ = srgb_to_hsv + elif isinstance(cs, HWBish): # pragma: no cover + to_ = hwb_to_srgb + from_ = srgb_to_hwb + else: # pragma: no cover + raise ValueError('Cannot coerce {} to an RGB space.'.format(cs.NAME)) + + class RGB(sRGBLinear): + """Custom RGB class.""" + + NAME = '-rgb-{}'.format(cs.NAME) + BASE = cs.NAME + GAMUT_CHECK = None + CLIP_SPACE = None + WHITE = cs.WHITE + DYAMIC_RANGE = cs.DYNAMIC_RANGE + INDEXES = cs.indexes() # type: ignore[attr-defined] + # Scale saturation and lightness (or HWB whiteness and blackness) + SCALE_SAT = cs.CHANNELS[INDEXES[1]].high + SCALE_LIGHT = cs.CHANNELS[INDEXES[1]].high + + def to_base(self, coords: Vector) -> Vector: + """Convert from RGB to HSL.""" + + coords = from_(coords) + if self.SCALE_SAT != 1: + coords[1] *= self.SCALE_SAT + if self.SCALE_LIGHT != 1: + coords[2] *= self.SCALE_LIGHT + ordered = [0.0, 0.0, 0.0] + for e, c in enumerate(coords): + ordered[self.INDEXES[e]] = c + return ordered + + def from_base(self, coords: Vector) -> Vector: + """Convert from HSL to RGB.""" + + coords = [coords[i] for i in self.INDEXES] + if self.SCALE_SAT != 1: + coords[1] /= self.SCALE_SAT + if self.SCALE_LIGHT != 1: + coords[2] /= self.SCALE_LIGHT + coords = to_(coords) + return coords + + class ColorRGB(OrigColor): # type: ignore[valid-type, misc] + """Custom color.""" + + ColorRGB.register(RGB()) + + return ColorRGB, RGB.NAME + + +def raytrace_box( + start: Vector, + end: Vector, + bmin: VectorLike = (0.0, 0.0, 0,0), + bmax: VectorLike = (1.0, 1.0, 1.0) +) -> Vector: + """ + Return the intersection of an axis aligned box using slab method. + + https://en.wikipedia.org/wiki/Slab_method + """ + + tfar = math.inf + tnear = -math.inf + direction = [] + for i in range(3): + a = start[i] + b = end[i] + d = b - a + direction.append(d) + bn = bmin[i] + bx = bmax[i] + + # Non parallel case + if d: + inv_d = 1 / d + t1 = (bn - a) * inv_d + t2 = (bx - a) * inv_d + tnear = max(min(t1, t2), tnear) + tfar = min(max(t1, t2), tfar) + + # Parallel case outside + elif a < bn or a > bx: + return [] + + # No hit + if tnear > tfar or tfar < 0: + return [] + + # Favor the intersection first in the direction start -> end + if tnear < 0: + tnear = tfar + + # An infinitesimally small point was used, not a ray. + # The origin is the intersection. Our use case will + # discard such scenarios, but others may wish to set + # intersection to origin. + if math.isinf(tnear): + return [] + + # Calculate intersection interpolation. + return [ + start[0] + direction[0] * tnear, + start[1] + direction[1] * tnear, + start[2] + direction[2] * tnear + ] + + +class RayTrace(Fit): + """Gamut mapping by using ray tracing.""" + + NAME = "raytrace" + PSPACE = "lch-d65" + + def fit( + self, + color: Color, + space: str, + *, + pspace: str | None = None, + lch: str | None = None, + **kwargs: Any + ) -> None: + """Scale the color within its gamut but preserve L and h as much as possible.""" + + is_lab = False + if lch is not None and pspace is None: # pragma: no cover + pspace = lch + warn_deprecated( + "'lch' parameter has been deprecated, please use 'pspace' to specify the perceptual space." + ) + elif pspace is None: + pspace = self.PSPACE + is_lab = isinstance(color.CS_MAP[pspace], Labish) + + cs = color.CS_MAP[space] + bmax = [1.0, 1.0, 1.0] + + # Requires an RGB-ish space, preferably a linear space. + # Coerce RGB cylinders with no defined RGB space to RGB + coerced = None + if not isinstance(cs, RGBish): + coerced = color + Color_, space = coerce_to_rgb(type(color), cs) + cs = Color_.CS_MAP[space] + color = Color_(color) + + # If there is a linear version of the RGB space, results will be + # better if we use that. If the target RGB space is HDR, we need to + # calculate the bounding box size based on the HDR limit in the linear space. + sdr = cs.DYNAMIC_RANGE != 'hdr' + linear = cs.linear() # type: ignore[attr-defined] + if linear and linear in color.CS_MAP: + if not sdr: + bmax = color.new(space, [chan.high for chan in cs.CHANNELS]).convert(linear)[:-1] + space = linear + + orig = color.space() + mapcolor = color.convert(pspace, norm=False) if orig != pspace else color.clone().normalize(nans=False) + achroma = mapcolor.clone() + + # Different perceptual spaces may have components in different orders, account for this + if is_lab: + l, a, b = mapcolor._space.indexes() # type: ignore[attr-defined] + light = mapcolor[l] + hue = alg.rect_to_polar(mapcolor[a], mapcolor[b])[1] + achroma[a] = 0 + achroma[b] = 0 + else: + l, c, h = achroma._space.indexes() # type: ignore[attr-defined] + light = mapcolor[l] + hue = mapcolor[h] + achroma[c] = 0 + + # Floating point math can cause some deviations between the max and min + # value in the achromatic RGB color. This is usually not an issue, but + # some perceptual spaces, such as CAM16 or HCT, may compensate for adapting + # luminance which may give an achromatic that is not quite achromatic, + # causing a more sizeable delta between the max and min value in the + # achromatic RGB color. To compensate for such deviations, take the + # average value of the RGB components and use that as the achromatic point. + # When dealing with simple floating point deviations, little to no change + # is observed, but for spaces like CAM16 or HCT, this can provide more + # reasonable gamut mapping. + achromatic = [sum(achroma.convert(space)[:-1]) / 3] * 3 + + # Return white or black if the achromatic version is not within the RGB cube. + # HDR colors currently use the RGB maximum lightness. We do not currently + # clip HDR colors to SDR white, but that could be done if required. + bmx = bmax[0] + point = achromatic[0] + if point >= bmx: + color.update(space, bmax, mapcolor[-1]) + elif point <= 0: + color.update(space, [0.0, 0.0, 0.0], mapcolor[-1]) + else: + # Create a ray from our current color to the color with zero chroma. + # Trace the line to the RGB cube finding the intersection. + # In between iterations, correct the L and H and then cast a ray + # to the new corrected color finding the intersection again. + mapcolor.convert(space, in_place=True) + for i in range(4): + if i: + mapcolor.convert(pspace, in_place=True) + if is_lab: + chroma = alg.rect_to_polar(mapcolor[a], mapcolor[b])[0] + ab = alg.polar_to_rect(chroma, hue) + mapcolor[l] = light + mapcolor[a] = ab[0] + mapcolor[b] = ab[1] + else: + mapcolor[l] = light + mapcolor[h] = hue + mapcolor.convert(space, in_place=True) + intersection = raytrace_box(achromatic, mapcolor[:-1], bmax=bmax) + if intersection: + mapcolor[:-1] = intersection + continue + break # pragma: no cover + + # Remove noise from floating point conversion. + color.update(space, [alg.clamp(x, 0.0, bmx) for x in mapcolor[:-1]], mapcolor[-1]) + + # If we have coerced a space to RGB, update the original + if coerced: + coerced.update(color) diff --git a/lib/coloraide/gamut/pointer.py b/lib/coloraide/gamut/pointer.py index 9cb2717d..3f129af0 100644 --- a/lib/coloraide/gamut/pointer.py +++ b/lib/coloraide/gamut/pointer.py @@ -3,14 +3,15 @@ Data used for the gamut: https://www.rit-mcsl.org/UsefulData/PointerData.xls. """ +from __future__ import annotations import math import bisect from ..spaces.lab import xyz_to_lab, lab_to_xyz from ..spaces.lch import lab_to_lch, lch_to_lab from .. import algebra as alg from .. import util -from ..types import Vector -from typing import TYPE_CHECKING, Tuple, List, Optional +from ..types import Vector, Matrix +from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -68,7 +69,7 @@ def lch_sc_to_xyY(lch: Vector) -> Vector: return util.xyz_to_xyY(lab_to_xyz(lch_to_lab(lch), XYZ_W), XYZ_W) -def to_lch_sc(color: 'Color') -> Vector: +def to_lch_sc(color: Color) -> Vector: """Convert a color to LCh with an SC illuminant.""" xyz = color.convert('xyz-d65').normalize(nans=False) @@ -76,7 +77,7 @@ def to_lch_sc(color: 'Color') -> Vector: return lab_to_lch(xyz_to_lab(xyz_sc, util.xy_to_xyz(WHITE_POINT_SC))) -def from_lch_sc(color: 'Color', lch: Vector) -> 'Color': +def from_lch_sc(color: Color, lch: Vector) -> Color: """Convert a color from LCh with an SC illuminant.""" xyz_sc = lab_to_xyz(lch_to_lab(lch), util.xy_to_xyz(WHITE_POINT_SC)) @@ -84,7 +85,7 @@ def from_lch_sc(color: 'Color', lch: Vector) -> 'Color': return color.update('xyz-d65', xyz_d65, color[-1]) -def closest_lightness(l: float) -> Tuple[int, float]: +def closest_lightness(l: float) -> tuple[int, float]: """Calculate the two closest lightness values and return the first index and interpolation factor.""" # Handle too low lightness inside tolerance @@ -106,7 +107,7 @@ def closest_lightness(l: float) -> Tuple[int, float]: return li, lf -def closest_hue(h: float) -> Tuple[int, float]: +def closest_hue(h: float) -> tuple[int, float]: """Calculate the two closest hues and return the first index and interpolation factor.""" hi = 0 @@ -141,7 +142,7 @@ def get_chroma_limit(l: float, h: float) -> float: return alg.lerp(alg.lerp(row1[li], row1[li + 1], lf), alg.lerp(row2[li], row2[li + 1], lf), hf) -def fit_pointer_gamut(color: 'Color') -> 'Color': +def fit_pointer_gamut(color: Color) -> Color: """Fit a color to the Pointer gamut.""" # Convert to CIE LCh with the SC illuminant @@ -159,7 +160,7 @@ def fit_pointer_gamut(color: 'Color') -> 'Color': return from_lch_sc(color, [new_l, new_c, h]) if adjusted else color -def in_pointer_gamut(color: 'Color', tolerance: float) -> bool: +def in_pointer_gamut(color: Color, tolerance: float) -> bool: """ See if color is within the pointer gamut. @@ -180,7 +181,7 @@ def in_pointer_gamut(color: 'Color', tolerance: float) -> bool: return c <= (get_chroma_limit(l, h) + tolerance) -def pointer_gamut_boundary(lightness: Optional[float] = None) -> List[Vector]: +def pointer_gamut_boundary(lightness: float | None = None) -> Matrix: """ Calculate the Pointer gamut boundary points for the given lightness. @@ -191,7 +192,7 @@ def pointer_gamut_boundary(lightness: Optional[float] = None) -> List[Vector]: # Maximum Pointer gamut boundary # For each hue, find the lightness/chroma point that is furthest away from the white point. if lightness is None: - max_gamut = [] # type: list[Vector] + max_gamut = [] # type: Matrix for i, h in enumerate(LCH_H): max_dxy = 0.0 max_xyy = [0.0, 0.0, 0.0] diff --git a/lib/coloraide/harmonies.py b/lib/coloraide/harmonies.py index 070fcfd5..4a2798f0 100644 --- a/lib/coloraide/harmonies.py +++ b/lib/coloraide/harmonies.py @@ -1,4 +1,6 @@ """Color harmonies.""" +from __future__ import annotations +import math from abc import ABCMeta, abstractmethod from . import algebra as alg from .spaces import Cylindrical, Labish, Regular, Space # noqa: F401 @@ -7,7 +9,7 @@ from .cat import WHITES from . import util from .types import Vector -from typing import TYPE_CHECKING, Optional, List, Dict, Any # noqa: F401 +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from .color import Color @@ -64,10 +66,10 @@ class Harmony(metaclass=ABCMeta): """Color harmony.""" @abstractmethod - def harmonize(self, color: 'Color', space: Optional[str]) -> List['Color']: + def harmonize(self, color: Color, space: str) -> list[Color]: """Get color harmonies.""" - def get_cylinder(self, color: 'Color', space: str) -> 'Color': + def get_cylinder(self, color: Color, space: str) -> Color: """Create a cylinder from a select number of color spaces on the fly.""" color = color.convert(space, norm=False).normalize() @@ -86,6 +88,12 @@ class HarmonyLCh(_HarmonyLCh): WHITE = cs.WHITE DYAMIC_RANGE = cs.DYNAMIC_RANGE INDEXES = cs.indexes() # type: ignore[attr-defined] + ORIG_SPACE = cs + + def is_achromatic(self, coords: Vector) -> bool | None: + """Check if space is achromatic.""" + + return self.ORIG_SPACE.is_achromatic(self.to_base(coords)) class ColorCyl(type(color)): # type: ignore[misc] """Custom color.""" @@ -104,9 +112,16 @@ class HarmonyHSL(_HarmonyHSL, HSL): SERIALIZE = ('---harmoncy-cylinder',) BASE = name GAMUT_CHECK = name + CLIP_SPACE = None WHITE = cs.WHITE DYAMIC_RANGE = cs.DYNAMIC_RANGE INDEXES = cs.indexes() if hasattr(cs, 'indexes') else [0, 1, 2] + ORIG_SPACE = cs + + def is_achromatic(self, coords: Vector) -> bool | None: + """Check if space is achromatic.""" + + return self.ORIG_SPACE.is_achromatic(self.to_base(coords)) class ColorCyl(type(color)): # type: ignore[no-redef, misc] """Custom color.""" @@ -122,32 +137,23 @@ class Monochromatic(Harmony): """ Monochromatic harmony. - Take a given color and create both tints and shades such that we have `RANGE` total steps ranging - from black -> color -> white. Normally, we will throw away pure black, pure white, and the duplicate - target color (as we interpolate with it on both sides) leaving us with RANGE - 3 colors to extract - the target `STEPS` from. The one exception is when targeting an achromatic color, and in that case, - we only throw away the duplicate color (though if a color is close enough to white or black, white - or black may not be included simply because we cannot get a reasonable step that includes it). - - Once we have our `RANGE`, we can extract a total of `STEPS` colors with the target color at the center - (when possible). If the target color is too close to the either the minimum or maximum color step, - there may not be enough tints or shades on one side, so the result may have to draw heavier on the - side that has more plentiful tints or shades which would cause the target color to shift from the center. - - The current `RANGE` was chosen as 12 as it seems to to provide OK contrast in most cases for the monochromatic - colors. The one exception is with a target color of black or very near black which may return at least one color - with very low contrast to black. Generally, extremely dark colors do not make a good target for color harmonies, - but it should be noted that OkLCh's lightness tends to the more darker side. The poor contrast may be - less with other color spaces. + Take a given color and create both tints and shades from black -> color -> white. + With a default count of 5, the goal is to generate 2 shades and 2 tints on either + side of the seed color, assuming a perfectly centered tone in the middle. If the color + is closer to black, more tints will be returned than shades and vice versa. + + If an achromatic color is specified as the input, black and white can be returned, otherwise, + black and white is usually not returned to only return non-achromatic palettes. """ DELTA_E = '2000' - RANGE = 12 - STEPS = 5 - def harmonize(self, color: 'Color', space: Optional[str]) -> List['Color']: + def harmonize(self, color: Color, space: str, count: int = 5) -> list[Color]: """Get color harmonies.""" + if count < 1: + raise ValueError('Cannot generate a monochromatic palette of {} colors.'.format(count)) + # Convert color space color1 = color.convert(space, norm=False).normalize() @@ -156,49 +162,65 @@ def harmonize(self, color: 'Color', space: Optional[str]) -> List['Color']: if not is_cyl and not isinstance(color1._space, (Labish, Regular)): raise ValueError('Unsupported color space type {}'.format(color.space())) - # Trim off black and white unless the color is achromatic, - # But always trim duplicate target color from left side. - if not color1.is_achromatic(): - ltrim, rtrim = slice(1, -1, None), slice(None, -1, None) - else: - ltrim, rtrim = slice(None, -1, None), slice(None, None, None) + # If only one color is requested, just return the current color. + if count == 1: + return [color1] # Create black and white so we can generate tints and shades # Ensure hue and alpha is masked so we don't interpolate them. mask = ['hue', 'alpha'] if is_cyl else ['alpha'] - w = color1.new('xyz-d65', WHITE, alg.nan) + w = color1.new('xyz-d65', WHITE, math.nan) + max_lum = w[1] w.convert(space, fit=True, in_place=True, norm=False).mask(mask, in_place=True) - b = color1.new('xyz-d65', BLACK, alg.nan) + b = color1.new('xyz-d65', BLACK, math.nan) + min_lum = b[1] b.convert(space, fit=True, in_place=True, norm=False).mask(mask, in_place=True) + # Minimum steps should be adjusted to account for trimming off white and + # black if the color is not achromatic. Additionally, prepare our slice + # to remove black and white if required, but always trim duplicate target + # color from left side. + if not color1.is_achromatic(): + min_steps = count + 3 + ltrim, rtrim = slice(1, -1, None), slice(None, -1, None) + else: + min_steps = count + 1 + ltrim, rtrim = slice(None, -1, None), slice(None, None, None) + # Calculate how many tints and shades we need to generate - db = b.delta_e(color1, method=self.DELTA_E) - dw = w.delta_e(color1, method=self.DELTA_E) - steps_w = int(alg.round_half_up((dw / (db + dw)) * self.RANGE)) - steps_b = self.RANGE - steps_w + luminance = color1.luminance() + if luminance <= min_lum: + steps_w = min_steps + steps_b = 0 + elif luminance >= max_lum: + steps_b = min_steps - 1 + steps_w = 0 + else: + db = b.delta_e(color1, method=self.DELTA_E) + dw = w.delta_e(color1, method=self.DELTA_E) + steps_w = int(alg.round_half_up((dw / (db + dw)) * min_steps)) + steps_b = min_steps - steps_w kwargs = { 'space': space, 'method': 'linear', 'out_space': space - } # type: Dict[str, Any] + } # type: dict[str, Any] # Very close to black or is black, no need to interpolate from black to current color if steps_b <= 1: left = [] if steps_b == 1: left.extend(color1.steps([b, color1], steps=steps_b, **kwargs)) - steps = min(self.RANGE - (1 + steps_b), steps_w) - right = color1.steps([color1, w], steps=steps, **kwargs)[rtrim] + right = color1.steps([color1, w], steps=min(min_steps - (1 + steps_b), steps_w), **kwargs)[rtrim] # Very close to white or is white, no need to interpolate from current color to white elif steps_w <= 1: right = [] if steps_w == 1: right.extend(color1.steps([color1, w], steps=steps_w, **kwargs)) - steps = min(self.RANGE - (1 + steps_w), steps_b) right.insert(0, color1.clone()) - left = color1.steps([b, color1], steps=steps, **kwargs)[ltrim] + left = color1.steps([b, color1], steps=min(min_steps - (1 + steps_w), steps_b), **kwargs)[ltrim] # Anything else in between else: @@ -208,12 +230,12 @@ def harmonize(self, color: 'Color', space: Optional[str]) -> List['Color']: # Extract a subset of the results len_l = len(left) len_r = len(right) - l = int(self.STEPS // 2) - r = l + (1 if self.STEPS % 2 else 0) + l = int(count // 2) + r = l + (1 if count % 2 else 0) if len_r < r: - return left[-self.STEPS + len_r:] + right + return left[-count + len_r:] + right elif len_l < l: - return left + right[:self.STEPS - len_l] + return left + right[:count - len_l] return left[-l:] + right[:r] @@ -225,7 +247,7 @@ def __init__(self) -> None: self.count = 12 - def harmonize(self, color: 'Color', space: str) -> List['Color']: + def harmonize(self, color: Color, space: str) -> list[Color]: """Get color harmonies.""" # Get the color cylinder @@ -253,7 +275,7 @@ def harmonize(self, color: 'Color', space: str) -> List['Color']: class Wheel(Geometric): """Generate a color wheel.""" - def harmonize(self, color: 'Color', space: str, count: int = 12) -> List['Color']: + def harmonize(self, color: Color, space: str, count: int = 12) -> list[Color]: """Generate a color wheel with the given count.""" self.count = count @@ -290,7 +312,7 @@ def __init__(self) -> None: class SplitComplementary(Harmony): """Split Complementary colors.""" - def harmonize(self, color: 'Color', space: str) -> List['Color']: + def harmonize(self, color: Color, space: str) -> list[Color]: """Get color harmonies.""" # Get the color cylinder @@ -312,7 +334,7 @@ def harmonize(self, color: 'Color', space: str) -> List['Color']: class Analogous(Harmony): """Analogous colors.""" - def harmonize(self, color: 'Color', space: str) -> List['Color']: + def harmonize(self, color: Color, space: str) -> list[Color]: """Get color harmonies.""" color1 = self.get_cylinder(color, space) @@ -333,7 +355,7 @@ def harmonize(self, color: 'Color', space: str) -> List['Color']: class TetradicRect(Harmony): """Tetradic (rectangular) colors.""" - def harmonize(self, color: 'Color', space: str) -> List['Color']: + def harmonize(self, color: Color, space: str) -> list[Color]: """Get color harmonies.""" # Get the color cylinder @@ -362,10 +384,10 @@ def harmonize(self, color: 'Color', space: str) -> List['Color']: 'analogous': Analogous(), 'mono': Monochromatic(), 'wheel': Wheel() -} # type: Dict[str, Harmony] +} # type: dict[str, Harmony] -def harmonize(color: 'Color', name: str, space: str, **kwargs: Any) -> List['Color']: +def harmonize(color: Color, name: str, space: str, **kwargs: Any) -> list[Color]: """Get specified color harmonies.""" h = SUPPORTED.get(name) diff --git a/lib/coloraide/interpolate/__init__.py b/lib/coloraide/interpolate/__init__.py index a375e2d2..abfeb1cf 100644 --- a/lib/coloraide/interpolate/__init__.py +++ b/lib/coloraide/interpolate/__init__.py @@ -13,14 +13,14 @@ Original Authors: Lea Verou, Chris Lilley License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json) """ +from __future__ import annotations import math import functools from abc import ABCMeta, abstractmethod -from .. import util from .. import algebra as alg from .. spaces import HSVish, HSLish, Cylindrical, RGBish, LChish, Labish -from ..types import Vector, ColorInput, Plugin -from typing import Callable, Dict, Tuple, Optional, Type, Sequence, Union, Mapping, List, Any, TYPE_CHECKING +from ..types import Matrix, Vector, ColorInput, Plugin +from typing import Callable, Sequence, Mapping, Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -43,7 +43,7 @@ def __init__(self, color: ColorInput, value: float) -> None: def midpoint(t: float, h: float = 0.5) -> float: """Midpoint easing function.""" - return 0.0 if h <= 0 or h >= 1 else alg.npow(t, math.log(0.5) / math.log(h)) + return 0.0 if h <= 0 or h >= 1 else alg.spow(t, math.log(0.5) / math.log(h)) def hint(mid: float) -> Callable[..., float]: @@ -52,7 +52,7 @@ def hint(mid: float) -> Callable[..., float]: return functools.partial(midpoint, h=mid) -def normalize_domain(d: List[float]) -> List[float]: +def normalize_domain(d: Vector) -> Vector: """Normalize domain between 0 and 1.""" total = d[-1] - d[0] @@ -70,18 +70,19 @@ class Interpolator(metaclass=ABCMeta): def __init__( self, - coordinates: List[Vector], + coordinates: Matrix, channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], + create: type[Color], + easings: list[Callable[..., float] | None], + stops: dict[int, float], space: str, out_space: str, - progress: Optional[Union[Callable[..., float], Mapping[str, Callable[..., float]]]], + progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None, premultiplied: bool, extrapolate: bool = False, - domain: Optional[Sequence[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, + domain: Sequence[float] | None = None, + padding: float | tuple[float, float] | None = None, + hue: str = 'shorter', **kwargs: Any ): """Initialize.""" @@ -98,21 +99,22 @@ def __init__( self.space = space self._out_space = out_space self.extrapolate = extrapolate - self.current_easing = None # type: Optional[Union[Callable[..., float], Mapping[str, Callable[..., float]]]] + self.current_easing = None # type: Mapping[str, Callable[..., float]] | Callable[..., float] | None + self.hue = hue cs = self.create.CS_MAP[space] - if isinstance(cs, Cylindrical): - self.hue_index = cs.hue_index() + if cs.is_polar(): + self.hue_index = cs.hue_index() # type: ignore[attr-defined] else: self.hue_index = -1 self.premultiplied = premultiplied # Calculate padded start and end - self._padding = None # type: Optional[Tuple[float, float]] + self._padding = None # type: tuple[float, float] | None if padding is not None: self.padding(padding) # Set the domain - self._domain = [] # type: List[float] + self._domain = [] # type: Vector if domain is not None: self.domain(domain) @@ -123,12 +125,18 @@ def discretize( steps: int = 2, max_steps: int = 1000, max_delta_e: float = 0, - delta_e: Optional[str] = None - ) -> None: + delta_e: str | None = None, + delta_e_args: dict[str, Any] | None = None, + ) -> Interpolator: """Make the interpolation a discretized interpolation.""" + from .linear import Linear + # Get the discrete steps for the new discrete interpolation - colors = self.steps(steps, max_steps, max_delta_e, delta_e) + colors = self.steps(steps, max_steps, max_delta_e, delta_e, delta_e_args) + + if not colors: + raise ValueError('Discrete interpolation requires at least 1 discrete step.') # Calculate new coordinate list and discrete stops total = len(colors) @@ -146,21 +154,28 @@ def discretize( coords.extend([step1, step2]) count += 2 - # Update colors and stops - self.coordinates = coords - self.length = len(self.coordinates) - self.stops = stops - self.start = self.stops[0] - self.end = self.stops[len(self.stops) - 1] - - # Reset features that were used to generate the discrete steps - self.easings = [None] * (self.length - 1) - self.progress = None - self.current_easing = None - self._padding = None - self._domain = [] - - self.setup() + hue = self.hue + if total == 1: + coords.extend([colors[-1][:], colors[-1][:]]) + stops[0] = 0.0 + stops[1] = 1.0 + hue = 'shorter' + + return Linear().interpolator( + coordinates=coords, + channel_names=self.channel_names, + create=self.create, + easings=[None] * (len(coords) - 1), + stops=stops, + space=self.space, + out_space=self._out_space, + progress=self.progress, + premultiplied=self.premultiplied, + extrapolate=self.extrapolate, + domain=[], + padding=None, + hue = hue + ) def out_space(self, space: str) -> None: """Set output space.""" @@ -169,7 +184,7 @@ def out_space(self, space: str) -> None: raise ValueError("'{}' is not a valid color space".format(space)) self._out_space = space - def padding(self, padding: Union[float, Sequence[float]]) -> None: + def padding(self, padding: float | Sequence[float]) -> None: """Add/adjust padding.""" # Make sure it is a sequence @@ -203,7 +218,7 @@ def domain(self, domain: Sequence[float]) -> None: # Ensure domain ascends. # If we have a domain of length 1, we will duplicate it. - d = [] # type: List[float] + d = [] # type: Vector if domain: length = len(domain) @@ -237,12 +252,16 @@ def steps( steps: int = 2, max_steps: int = 1000, max_delta_e: float = 0, - delta_e: Optional[str] = None - ) -> List['Color']: + delta_e: str | None = None, + delta_e_args: dict[str, Any] | None = None, + ) -> list[Color]: """Steps.""" actual_steps = steps + if delta_e_args is None: + delta_e_args = {} + # Allocate at least two steps if we are doing a maximum delta E, if max_delta_e != 0 and actual_steps < 2: actual_steps = 2 @@ -251,7 +270,7 @@ def steps( if max_steps is not None: actual_steps = min(actual_steps, max_steps) - ret = [] # type: List[Tuple[float, Color]] + ret = [] # type: list[tuple[float, Color]] if actual_steps == 1: ret = [(0.5, self(0.5))] elif actual_steps > 1: @@ -271,7 +290,8 @@ def steps( m_delta, ret[i - 1][1].delta_e( ret[i][1], - method=delta_e + method=delta_e, + **delta_e_args ) ) @@ -290,8 +310,8 @@ def steps( color = self(p) m_delta = max( m_delta, - color.delta_e(prev[1], method=delta_e), - color.delta_e(cur[1], method=delta_e) + color.delta_e(prev[1], method=delta_e, **delta_e_args), + color.delta_e(cur[1], method=delta_e, **delta_e_args) ) ret.insert(index, (p, color)) total += 1 @@ -299,7 +319,7 @@ def steps( return [i[1] for i in ret] - def premultiply(self, coords: Vector, alpha: Optional[float] = None) -> None: + def premultiply(self, coords: Vector, alpha: float | None = None) -> None: if alpha is not None: coords[-1] = alpha @@ -333,7 +353,7 @@ def postdivide(self, coords: Vector) -> None: coords[i] = value / alpha - def begin(self, point: float, first: float, last: float, index: int) -> 'Color': + def begin(self, point: float, first: float, last: float, index: int) -> Color: """ Begin interpolation. @@ -409,7 +429,7 @@ def scale(self, point: float) -> float: point = size * index + (adjusted * size) return point - def __call__(self, point: float) -> 'Color': + def __call__(self, point: float) -> Color: """Find which leg of the interpolation the request is between.""" if self._domain: @@ -450,24 +470,25 @@ class Interpolate(Plugin, metaclass=ABCMeta): @abstractmethod def interpolator( self, - coordinates: List[Vector], + coordinates: Matrix, channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], + create: type[Color], + easings: list[Callable[..., float] | None], + stops: dict[int, float], space: str, out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], + progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None, premultiplied: bool, extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, + domain: Vector | None = None, + padding: float | tuple[float, float] | None = None, + hue: str = 'shorter', **kwargs: Any ) -> Interpolator: """Get the interpolator object.""" -def calc_stops(stops: Dict[int, float], count: int) -> Dict[int, float]: +def calc_stops(stops: dict[int, float], count: int) -> dict[int, float]: """Calculate stops.""" # Ensure the first stop is set to zero if not explicitly set @@ -533,9 +554,9 @@ def calc_stops(stops: Dict[int, float], count: int) -> Dict[int, float]: def process_mapping( - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], + progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None, aliases: Mapping[str, str] -) -> Optional[Union[Callable[..., float], Mapping[str, Callable[..., float]]]]: +) -> Mapping[str, Callable[..., float]] | Callable[..., float] | None: """Process a mapping, such that it is not using aliases.""" if not isinstance(progress, Mapping): @@ -543,94 +564,7 @@ def process_mapping( return {aliases.get(k, k): v for k, v in progress.items()} -def adjust_shorter(h1: float, h2: float, offset: float) -> Tuple[float, float]: - """Adjust the given hues.""" - - d = h2 - h1 - if d > 180: - h2 -= 360.0 - offset -= 360.0 - elif d < -180: - h2 += 360 - offset += 360.0 - return h2, offset - - -def adjust_longer(h1: float, h2: float, offset: float) -> Tuple[float, float]: - """Adjust the given hues.""" - - d = h2 - h1 - if 0 < d < 180: - h2 -= 360.0 - offset -= 360.0 - elif -180 < d <= 0: - h2 += 360 - offset += 360.0 - return h2, offset - - -def adjust_increase(h1: float, h2: float, offset: float) -> Tuple[float, float]: - """Adjust the given hues.""" - - if h2 < h1: - h2 += 360.0 - offset += 360.0 - return h2, offset - - -def adjust_decrease(h1: float, h2: float, offset: float) -> Tuple[float, float]: - """Adjust the given hues.""" - - if h2 > h1: - h2 -= 360.0 - offset -= 360.0 - return h2, offset - - -def normalize_hue( - color1: Vector, - color2: Optional[Vector], - index: int, - offset: float, - hue: str, - fallback: Optional[float] -) -> Tuple[Vector, float]: - """Normalize hues according the hue specifier.""" - - if hue == 'specified': - return (color2 or color1), offset - - # Probably the first hue - if color2 is None: - color1[index] = util.constrain_hue(color1[index]) - return color1, offset - - if hue == 'shorter': - adjuster = adjust_shorter - elif hue == 'longer': - adjuster = adjust_longer - elif hue == 'increasing': - adjuster = adjust_increase - elif hue == 'decreasing': - adjuster = adjust_decrease - else: - raise ValueError("Unknown hue adjuster '{}'".format(hue)) - - c1 = color1[index] + offset - c2 = util.constrain_hue(color2[index]) + offset - - # Adjust hue, handle gaps across `NaN`s - if not math.isnan(c2): - if not math.isnan(c1): - c2, offset = adjuster(c1, c2, offset) - elif fallback is not None: - c2, offset = adjuster(fallback, c2, offset) - - color2[index] = c2 - return color2, offset - - -def carryforward_convert(color: 'Color', space: str, hue_index: int, powerless: bool) -> None: # pragma: no cover +def carryforward_convert(color: Color, space: str, hue_index: int, powerless: bool) -> None: # pragma: no cover """Carry forward undefined values during conversion.""" carry = [] @@ -720,16 +654,16 @@ def carryforward_convert(color: 'Color', space: str, hue_index: int, powerless: def interpolator( interpolator: str, - create: Type['Color'], - colors: Sequence[Union[ColorInput, stop, Callable[..., float]]], - space: Optional[str], - out_space: Optional[str], - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], + create: type[Color], + colors: Sequence[ColorInput | stop | Callable[..., float]], + space: str | None, + out_space: str | None, + progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None, hue: str, premultiplied: bool, extrapolate: bool, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, + domain: Vector | None = None, + padding: float | tuple[float, float] | None = None, carryforward: bool = False, powerless: bool = False, **kwargs: Any @@ -746,6 +680,9 @@ def interpolator( if space is None: space = create.INTERPOLATE + if not colors: + raise ValueError('At least one color must be specified.') + if isinstance(colors[0], stop): current = create(colors[0].color) stops[0] = colors[0].stop @@ -769,19 +706,9 @@ def interpolator( elif powerless and is_cyl and current.is_achromatic(): current[hue_index] = math.nan - # Normalize hue - offset = 0.0 - norm_coords = current[:] - fallback = None - if hue_index >= 0: - h = norm_coords[hue_index] - norm_coords, offset = normalize_hue(norm_coords, None, hue_index, offset, hue, fallback) - if not math.isnan(h): - fallback = h - easing = None # type: Any easings = [] # type: Any - coords = [norm_coords] + coords = [current[:]] i = 0 for x in colors[1:]: @@ -807,16 +734,8 @@ def interpolator( elif powerless and is_cyl and color.is_achromatic(): color[hue_index] = math.nan - # Normalize the hue - norm_coords = color[:] - if hue_index >= 0: - h = norm_coords[hue_index] - norm_coords, offset = normalize_hue(current[:], norm_coords, hue_index, offset, hue, fallback) - if not math.isnan(h): - fallback = h - # Create an entry interpolating the current color and the next color - coords.append(norm_coords) + coords.append(color[:]) easings.append(easing if easing is not None else progress) # The "next" color is now the "current" color @@ -824,11 +743,16 @@ def interpolator( current = color i += 1 - if i < 2: - raise ValueError('Need at least two colors to interpolate') + if i == 1: + coords.append(coords[-1][:]) + easings.append(None) + stops[i] = None + hue = 'shorter' + i += 1 # Calculate stops stops = calc_stops(stops, i) + kwargs['hue'] = hue # Send the interpolation list along with the stop map to the Piecewise interpolator return plugin.interpolator( diff --git a/lib/coloraide/interpolate/bspline.py b/lib/coloraide/interpolate/bspline.py index 3c7fdeff..c75cac96 100644 --- a/lib/coloraide/interpolate/bspline.py +++ b/lib/coloraide/interpolate/bspline.py @@ -5,14 +5,12 @@ https://www.math.ucla.edu/~baker/149.1.02w/handouts/dd_splines.pdf http://www2.cs.uregina.ca/~anima/408/Notes/Interpolation/UniformBSpline.htm """ +from __future__ import annotations from .. import algebra as alg from .continuous import InterpolatorContinuous from ..interpolate import Interpolator, Interpolate from ..types import Vector -from typing import Optional, Callable, Mapping, List, Union, Sequence, Dict, Any, Tuple, Type, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from typing import Any class InterpolatorBSpline(InterpolatorContinuous): @@ -79,36 +77,7 @@ class BSpline(Interpolate): NAME = "bspline" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: """Return the B-spline interpolator.""" - return InterpolatorBSpline( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + return InterpolatorBSpline(*args, **kwargs) diff --git a/lib/coloraide/interpolate/bspline_natural.py b/lib/coloraide/interpolate/bspline_natural.py index fdc8b89a..78cc9b92 100644 --- a/lib/coloraide/interpolate/bspline_natural.py +++ b/lib/coloraide/interpolate/bspline_natural.py @@ -3,14 +3,11 @@ https://www.math.ucla.edu/~baker/149.1.02w/handouts/dd_splines.pdf. """ +from __future__ import annotations from .. interpolate import Interpolate, Interpolator from .bspline import InterpolatorBSpline from .. import algebra as alg -from ..types import Vector -from typing import List, Sequence, Any, Optional, Union, Mapping, Callable, Dict, Tuple, Type, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from typing import Any class InterpolatorNaturalBSpline(InterpolatorBSpline): @@ -38,36 +35,7 @@ class NaturalBSpline(Interpolate): NAME = "natural" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: """Return the natural B-spline interpolator.""" - return InterpolatorNaturalBSpline( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + return InterpolatorNaturalBSpline(*args, **kwargs) diff --git a/lib/coloraide/interpolate/catmull_rom.py b/lib/coloraide/interpolate/catmull_rom.py index 4c26c387..0a39f321 100644 --- a/lib/coloraide/interpolate/catmull_rom.py +++ b/lib/coloraide/interpolate/catmull_rom.py @@ -3,14 +3,11 @@ http://www2.cs.uregina.ca/~anima/408/Notes/Interpolation/Parameterized-Curves-Summary.htm """ +from __future__ import annotations from .bspline import InterpolatorBSpline from ..interpolate import Interpolator, Interpolate from .. import algebra as alg -from ..types import Vector -from typing import Optional, Callable, Mapping, List, Union, Sequence, Dict, Tuple, Any, Type, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from typing import Any class InterpolatorCatmullRom(InterpolatorBSpline): @@ -28,36 +25,7 @@ class CatmullRom(Interpolate): NAME = "catrom" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: """Return the Catmull-Rom interpolator.""" - return InterpolatorCatmullRom( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + return InterpolatorCatmullRom(*args, **kwargs) diff --git a/lib/coloraide/interpolate/continuous.py b/lib/coloraide/interpolate/continuous.py index 063b763f..11444551 100644 --- a/lib/coloraide/interpolate/continuous.py +++ b/lib/coloraide/interpolate/continuous.py @@ -1,18 +1,108 @@ """Continuous interpolation.""" +from __future__ import annotations import math from .. import algebra as alg from ..interpolate import Interpolator, Interpolate from ..types import Vector -from typing import Callable, Mapping, Sequence, Any, TYPE_CHECKING -from typing import Optional, Callable, Mapping, List, Union, Sequence, Dict, Tuple, Any, Type, TYPE_CHECKING +from typing import Any -if TYPE_CHECKING: # pragma: no cover - from ..color import Color + +def adjust_shorter(h1: float, h2: float, offset: float) -> tuple[float, float]: + """Adjust the given hues.""" + + d = h2 - h1 + if d > 180: + h2 -= 360.0 + offset -= 360.0 + elif d < -180: + h2 += 360 + offset += 360.0 + return h2, offset + + +def adjust_longer(h1: float, h2: float, offset: float) -> tuple[float, float]: + """Adjust the given hues.""" + + d = h2 - h1 + if 0 < d < 180: + h2 -= 360.0 + offset -= 360.0 + elif -180 < d <= 0: + h2 += 360 + offset += 360.0 + return h2, offset + + +def adjust_increase(h1: float, h2: float, offset: float) -> tuple[float, float]: + """Adjust the given hues.""" + + if h2 < h1: + h2 += 360.0 + offset += 360.0 + return h2, offset + + +def adjust_decrease(h1: float, h2: float, offset: float) -> tuple[float, float]: + """Adjust the given hues.""" + + if h2 > h1: + h2 -= 360.0 + offset -= 360.0 + return h2, offset class InterpolatorContinuous(Interpolator): """Interpolate with continuous piecewise.""" + def normalize_hue( + self, + color1: Vector, + color2: Vector | None, + offset: float, + hue: str, + fallback: float | None + ) -> tuple[Vector, float]: + """ + Normalize hues according the hue specifier. + + Hues are normalized in a continuous way such that the fix-up is applied + relative to the hues that come before it. + """ + + index = self.hue_index + + if hue == 'specified': + return (color2 or color1), offset + + # Probably the first hue + if color2 is None: + color1[index] = color1[index] % 360 + return color1, offset + + if hue == 'shorter': + adjuster = adjust_shorter + elif hue == 'longer': + adjuster = adjust_longer + elif hue == 'increasing': + adjuster = adjust_increase + elif hue == 'decreasing': + adjuster = adjust_decrease + else: + raise ValueError("Unknown hue adjuster '{}'".format(hue)) + + c1 = color1[index] + offset + c2 = (color2[index] % 360) + offset + + # Adjust hue, handle gaps across `NaN`s + if not math.isnan(c2): + if not math.isnan(c1): + c2, offset = adjuster(c1, c2, offset) + elif fallback is not None: + c2, offset = adjuster(fallback, c2, offset) + + color2[index] = c2 + return color2, offset + def handle_undefined(self) -> None: """ Handle null values. @@ -28,6 +118,28 @@ def handle_undefined(self) -> None: """ coords = self.coordinates + end = self.length - 2 + hue_index = self.hue_index + + # Normalize hue + offset = 0.0 + fallback = None + if hue_index >= 0: + first = self.coordinates[0] + h = first[hue_index] + self.coordinates[0], offset = self.normalize_hue(first, None, offset, self.hue, fallback) + if not math.isnan(h): + fallback = h + + i = 0 + while i <= end: + c1, c2 = self.coordinates[i:i + 2] + if hue_index >= 0: + h = c2[hue_index] + self.coordinates[i + 1], offset = self.normalize_hue(c1, c2, offset, self.hue, fallback) + if not math.isnan(h): + fallback = h + i += 1 # Process each set of coordinates alpha = len(coords[0]) - 1 @@ -128,36 +240,7 @@ class Continuous(Interpolate): NAME = "continuous" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: - """Return the B-spline interpolator.""" - - return InterpolatorContinuous( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + """Return the continuous interpolator.""" + + return InterpolatorContinuous(*args, **kwargs) diff --git a/lib/coloraide/interpolate/css_linear.py b/lib/coloraide/interpolate/css_linear.py new file mode 100644 index 00000000..520b352f --- /dev/null +++ b/lib/coloraide/interpolate/css_linear.py @@ -0,0 +1,91 @@ +"""Piecewise linear interpolation.""" +from __future__ import annotations +import math +from .linear import InterpolatorLinear +from ..interpolate import Interpolator, Interpolate +from ..types import Vector +from typing import Any + + +class InterpolatorCSSLinear(InterpolatorLinear): + """Interpolate multiple ranges of colors using linear, Piecewise interpolation, but adhere to CSS requirements.""" + + def normalize_hue( + self, + color1: Vector, + color2: Vector, + hue: str + ) -> None: + """ + Adjust hues. + + Hues are applied to match CSS. This means the undefined hues are resolved + before fix-up such that during hue-fix, undefined hues will assume the value + of the other color (if the hue is defined) creating an arc length. Since + interpolation between a non-achromatic color and achromatic color will + now have a false arc length, hue specifications such as shorter and longer + will produce different results in such cases. This is done purposely in + CSS. + + In non-CSS linear interpolation, undefined hue resolution is performed later + and yields a result that such that their is no hue arc which gives more + intuitive results with achromatic colors. + """ + + index = self.hue_index + + c1 = color1[index] + c2 = color2[index] + + is_nan1 = math.isnan(c1) + is_nan2 = math.isnan(c2) + + if is_nan1 and is_nan2: + return + elif is_nan1: + c1 = c2 + elif is_nan2: + c2 = c1 + + if hue == "specified": + return + + c1 %= 360 + c2 %= 360 + + if hue == "shorter": + if c2 - c1 > 180: + c1 += 360 + elif c2 - c1 < -180: + c2 += 360 + + elif hue == "longer": + if 0 < (c2 - c1) < 180: + c1 += 360 + elif -180 < (c2 - c1) <= 0: + c2 += 360 + + elif hue == "increasing": + if c2 < c1: + c2 += 360 + + elif hue == "decreasing": + if c1 < c2: + c1 += 360 + + else: + raise ValueError("Unknown hue adjuster '{}'".format(hue)) + + color1[index] = c1 + color2[index] = c2 + + +class CSSLinear(Interpolate): + """CSS Linear interpolation plugin.""" + + NAME = "css-linear" + + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + """Return the CSS linear interpolator.""" + + return InterpolatorCSSLinear(*args, **kwargs) diff --git a/lib/coloraide/interpolate/linear.py b/lib/coloraide/interpolate/linear.py index e1bed7fa..ebe5f9c7 100644 --- a/lib/coloraide/interpolate/linear.py +++ b/lib/coloraide/interpolate/linear.py @@ -1,17 +1,72 @@ """Piecewise linear interpolation.""" +from __future__ import annotations import math from .. import algebra as alg from ..interpolate import Interpolator, Interpolate from ..types import Vector -from typing import Optional, Callable, Mapping, Union, Any, Type, Sequence, List, Tuple, Dict, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from typing import Any class InterpolatorLinear(Interpolator): """Interpolate multiple ranges of colors using linear, Piecewise interpolation.""" + def normalize_hue( + self, + color1: Vector, + color2: Vector, + hue: str + ) -> None: + """ + Adjust hues. + + Undefined hues are not resolved at this point in time. + When interpolating between achromatic colors, hue specifications + such as shorter and longer will have no affect as undefined hues + will remain undefined meaning there is no arc length to choose + between. This gives more intuitive interpolation results. + """ + + index = self.hue_index + + c1 = color1[index] + c2 = color2[index] + + if hue == "specified": + return + + c1 %= 360 + c2 %= 360 + + if math.isnan(c1) or math.isnan(c2): + return + + if hue == "shorter": + if c2 - c1 > 180: + c1 += 360 + elif c2 - c1 < -180: + c2 += 360 + + elif hue == "longer": + if 0 < (c2 - c1) < 180: + c1 += 360 + elif -180 < (c2 - c1) <= 0: + c2 += 360 + + elif hue == "increasing": + if c2 < c1: + c2 += 360 + + elif hue == "decreasing": + if c1 < c2: + c1 += 360 + + else: + raise ValueError("Unknown hue adjuster '{}'".format(hue)) + + color1[index] = c1 + color2[index] = c2 + + def setup(self) -> None: """Setup for linear interpolation.""" @@ -28,6 +83,10 @@ def setup(self) -> None: self.coordinates.insert(i + 2, c2[:]) end += 1 + if self.hue_index >= 0: + self.normalize_hue(c1, c2, self.hue) + self.coordinates[i:i + 2] = [c1, c2] + # If we have a NaN for one alpha and the other alpha is not # Use the non-NaN alpha, but if we are premultiplied, we need # to now premultiply that coordinate set. @@ -69,7 +128,7 @@ def interpolate( # Both values are undefined, so return undefined if math.isnan(a) and math.isnan(b): - value = alg.nan + value = math.nan # One channel is undefined, take the one that is not elif math.isnan(a): @@ -91,36 +150,7 @@ class Linear(Interpolate): NAME = "linear" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: """Return the linear interpolator.""" - return InterpolatorLinear( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + return InterpolatorLinear(*args, **kwargs) diff --git a/lib/coloraide/interpolate/monotone.py b/lib/coloraide/interpolate/monotone.py index ccd29b48..239bc763 100644 --- a/lib/coloraide/interpolate/monotone.py +++ b/lib/coloraide/interpolate/monotone.py @@ -1,12 +1,9 @@ """Monotone interpolation based on a Hermite interpolation spline.""" +from __future__ import annotations from .bspline import InterpolatorBSpline from ..interpolate import Interpolator, Interpolate from .. import algebra as alg -from ..types import Vector -from typing import Optional, Callable, Mapping, List, Union, Sequence, Dict, Tuple, Any, Type, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from typing import Any class InterpolatorMonotone(InterpolatorBSpline): @@ -24,36 +21,7 @@ class Monotone(Interpolate): NAME = "monotone" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: """Return the monotone interpolator.""" - return InterpolatorMonotone( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + return InterpolatorMonotone(*args, **kwargs) diff --git a/lib/coloraide/spaces/__init__.py b/lib/coloraide/spaces/__init__.py index 2509d4a5..f7018e75 100644 --- a/lib/coloraide/spaces/__init__.py +++ b/lib/coloraide/spaces/__init__.py @@ -1,10 +1,10 @@ """Color base.""" +from __future__ import annotations from abc import ABCMeta, abstractmethod from ..channels import Channel from ..css import serialize -from ..deprecate import deprecated from ..types import VectorLike, Vector, Plugin -from typing import Tuple, Dict, Optional, Union, Any, List, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence import math if TYPE_CHECKING: # pragma: no cover @@ -18,6 +18,11 @@ class Regular: class Cylindrical: """Cylindrical space.""" + def radial_name(self) -> str: + """Radial name.""" + + return "s" + def hue_name(self) -> str: """Hue channel name.""" @@ -28,30 +33,40 @@ def hue_index(self) -> int: # pragma: no cover return self.get_channel_index(self.hue_name()) # type: ignore[no-any-return, attr-defined] + def radial_index(self) -> int: # pragma: no cover + """Get radial index.""" + + return self.get_channel_index(self.radial_name()) # type: ignore[no-any-return, attr-defined] + class RGBish(Regular): """RGB-ish space.""" - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return RGB-ish names in order R G B.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def indexes(self) -> List[int]: + def indexes(self) -> list[int]: """Return the index of RGB-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] + def linear(self) -> str: + """Will return the name of the space which is the linear version of itself (if available).""" + + return '' + class HSLish(Cylindrical): """HSL-ish space.""" - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return HSL-ish names in order H S L.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def indexes(self) -> List[int]: + def indexes(self) -> list[int]: """Return the index of HSL-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] @@ -60,12 +75,12 @@ def indexes(self) -> List[int]: class HSVish(Cylindrical): """HSV-ish space.""" - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return HSV-ish names in order H S V.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def indexes(self) -> List[int]: + def indexes(self) -> list[int]: """Return the index of HSV-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] @@ -74,12 +89,17 @@ def indexes(self) -> List[int]: class HWBish(Cylindrical): """HWB-ish space.""" - def names(self) -> Tuple[str, ...]: + def radial_name(self) -> str: + """Radial name.""" + + return "w" + + def names(self) -> tuple[str, ...]: """Return HWB-ish names in order H W B.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def indexes(self) -> List[int]: + def indexes(self) -> list[int]: """Return the index of HWB-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] @@ -88,24 +108,12 @@ def indexes(self) -> List[int]: class Labish: """Lab-ish color spaces.""" - @deprecated("Please use 'names' instead.") - def labish_names(self) -> Tuple[str, ...]: # pragma: no cover - """Return Lab-ish names in the order L a b.""" - - return self.names() - - @deprecated("Please use 'indexes' instead.") - def labish_indexes(self) -> List[int]: # pragma: no cover - """Return the index of the Lab-ish channels.""" - - return self.indexes() - - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return Lab-ish names in the order L a b.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def labish_indexes(self) -> List[int]: # pragma: no cover + def indexes(self) -> list[int]: """Return the index of the Lab-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] @@ -114,24 +122,17 @@ def labish_indexes(self) -> List[int]: # pragma: no cover class LChish(Cylindrical): """LCh-ish color spaces.""" - @deprecated("Please use 'names' instead.") - def lchish_names(self) -> Tuple[str, ...]: # pragma: no cover - """Return LCh-ish names in the order L c h.""" - - return self.names() - - @deprecated("Please use 'indexes' instead.") - def lchish_indexes(self) -> List[int]: # pragma: no cover - """Return the index of the Lab-ish channels.""" + def radial_name(self) -> str: + """Radial name.""" - return self.indexes() + return "c" - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return LCh-ish names in the order L c h.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def indexes(self) -> List[int]: + def indexes(self) -> list[int]: """Return the index of the Lab-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] @@ -143,11 +144,11 @@ def indexes(self) -> List[int]: class SpaceMeta(ABCMeta): """Ensure on subclass that the subclass has new instances of mappings.""" - def __init__(cls, name: str, bases: Tuple[object, ...], clsdict: Dict[str, Any]) -> None: + def __init__(cls, name: str, bases: tuple[object, ...], clsdict: dict[str, Any]) -> None: """Copy mappings on subclass.""" if len(cls.mro()) > 2: - cls.CHANNEL_ALIASES = cls.CHANNEL_ALIASES.copy() # type: Dict[str, str] + cls.CHANNEL_ALIASES = cls.CHANNEL_ALIASES.copy() # type: dict[str, str] class Space(Plugin, metaclass=SpaceMeta): @@ -157,22 +158,28 @@ class Space(Plugin, metaclass=SpaceMeta): # Color space name NAME = "" # Serialized name - SERIALIZE = () # type: Tuple[str, ...] + SERIALIZE = () # type: tuple[str, ...] # Channel names - CHANNELS = () # type: Tuple[Channel, ...] + CHANNELS = () # type: tuple[Channel, ...] # Channel aliases - CHANNEL_ALIASES = {} # type: Dict[str, str] + CHANNEL_ALIASES = {} # type: dict[str, str] # Enable or disable default color format parsing and serialization. COLOR_FORMAT = True - # Should this color also be checked in a different color space? Only when set to a string (specifying a color space) - # will the default gamut checking also check the specified space as well as the current. + # Some color spaces are a transform of a specific RGB color space gamut, e.g. HSL has a gamut of sRGB. + # When testing or gamut mapping a color within the current color space's gamut, `GAMUT_CHECK` will + # declare which space must be used as reference if anything other than the current space is required. # - # Gamut checking: - # The specified color space will be checked first followed by the original. Assuming the parent color space fits, - # the original should fit as well, but there are some cases when a parent color space that is slightly out of - # gamut, when evaluated with a threshold, may appear to be in gamut enough, but when checking the original color - # space, the values can be greatly out of specification (looking at you HSL). - GAMUT_CHECK = None # type: Optional[str] + # Specifically, when testing if a color is in gamut, both the origin space and the specified gamut + # space will be checked as sometimes a color is within the threshold of being "close enough" to the gamut, + # but the color can still be far outside the origin space's coordinates. Checking both ensures sane values + # that are also close enough to the gamut. + # + # When actually gamut mapping, only the gamut space is used, if none is specified, the origin space is used. + GAMUT_CHECK = None # type: str | None + # `CLIP_SPACE` forces a different space to be used for clipping than what is specified by `GAMUT_CHECK`. + # This is used in cases like HSL where the `GAMUT_CHECK` space is sRGB, but we want to clip in HSL as it + # is still reasonable and faster. + CLIP_SPACE = None # type: str | None # When set to `True`, this denotes that the color space has the ability to represent out of gamut in colors in an # extended range. When interpolation is done, if colors are interpolated in a smaller gamut than the colors being # interpolated, the colors will usually be gamut mapped, but if the interpolation space happens to support extended @@ -188,12 +195,21 @@ def __init__(self, **kwargs: Any) -> None: """Initialize.""" self.channels = self.CHANNELS + (alpha_channel,) + self._chan_index = {c: e for e, c in enumerate(self.channels)} # type: dict[str, int] self._color_ids = (self.NAME,) if not self.SERIALIZE else self.SERIALIZE + self._percents = ([True] * (len(self.channels) - 1)) + [False] + self._polar = isinstance(self, Cylindrical) + + def is_polar(self) -> bool: + """Return if the space is polar.""" + + return self._polar def get_channel_index(self, name: str) -> int: """Get channel index.""" - return self.channels.index(self.CHANNEL_ALIASES.get(name, name)) + idx = self._chan_index.get(self.CHANNEL_ALIASES.get(name, name)) + return int(name) if idx is None else idx def resolve_channel(self, index: int, coords: Vector) -> float: """Resolve channels.""" @@ -201,12 +217,24 @@ def resolve_channel(self, index: int, coords: Vector) -> float: value = coords[index] return self.channels[index].nans if math.isnan(value) else value - def _serialize(self) -> Tuple[str, ...]: + def _serialize(self) -> tuple[str, ...]: """Get the serialized name.""" return self._color_ids - def is_achromatic(self, coords: Vector) -> Optional[bool]: # pragma: no cover + def normalize(self, coords: Vector) -> Vector: + """ + Normalize coordinates. + + This allows a color space to normalize valid, but non-standard coordinates. + An example is cylindrical spaces with negative chroma/saturation. Such models + often have a valid, positive chroma/saturation and hue configuration that + matches the same color. + """ + + return coords + + def is_achromatic(self, coords: Vector) -> bool | None: # pragma: no cover """Check if color is achromatic.""" return None @@ -227,12 +255,13 @@ def from_base(self, coords: Vector) -> Vector: # pragma: no cover def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[bool, str] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: str | bool | dict[str, Any] = True, none: bool = False, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS 'color' string: `color(space coords+ / alpha)`.""" @@ -243,7 +272,8 @@ def to_string( alpha=alpha, precision=precision, fit=fit, - none=none + none=none, + percent=percent ) def match( @@ -251,7 +281,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a color by string.""" return None diff --git a/lib/coloraide/spaces/a98_rgb.py b/lib/coloraide/spaces/a98_rgb.py index c5cf51e3..f23f996c 100644 --- a/lib/coloraide/spaces/a98_rgb.py +++ b/lib/coloraide/spaces/a98_rgb.py @@ -1,6 +1,6 @@ """A98 RGB color class.""" -from ..cat import WHITES -from .srgb import sRGB +from __future__ import annotations +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -8,21 +8,25 @@ def lin_a98rgb(rgb: Vector) -> Vector: """Convert an array of a98-rgb values in the range 0.0 - 1.0 to linear light (un-corrected) form.""" - return [alg.npow(val, 563 / 256) for val in rgb] + return [alg.spow(val, 563 / 256) for val in rgb] def gam_a98rgb(rgb: Vector) -> Vector: """Convert an array of linear-light a98-rgb in the range 0.0-1.0 to gamma corrected form.""" - return [alg.npow(val, 256 / 563) for val in rgb] + return [alg.spow(val, 256 / 563) for val in rgb] -class A98RGB(sRGB): +class A98RGB(sRGBLinear): """A98 RGB class.""" BASE = "a98-rgb-linear" NAME = "a98-rgb" - WHITE = WHITES['2deg']['D65'] + + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE def to_base(self, coords: Vector) -> Vector: """To XYZ from A98 RGB.""" diff --git a/lib/coloraide/spaces/a98_rgb_linear.py b/lib/coloraide/spaces/a98_rgb_linear.py index c5f0c249..e0168db3 100644 --- a/lib/coloraide/spaces/a98_rgb_linear.py +++ b/lib/coloraide/spaces/a98_rgb_linear.py @@ -1,6 +1,6 @@ """Linear A98 RGB color class.""" -from ..cat import WHITES -from .srgb import sRGB +from __future__ import annotations +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -27,22 +27,21 @@ def lin_a98rgb_to_xyz(rgb: Vector) -> Vector: https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf """ - return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_a98rgb(xyz: Vector) -> Vector: """Convert XYZ to linear-light a98-rgb.""" - return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) -class A98RGBLinear(sRGB): +class A98RGBLinear(sRGBLinear): """Linear A98 RGB class.""" BASE = "xyz-d65" NAME = "a98-rgb-linear" SERIALIZE = ('--a98-rgb-linear',) - WHITE = WHITES['2deg']['D65'] def to_base(self, coords: Vector) -> Vector: """To XYZ from A98 RGB.""" diff --git a/lib/coloraide/spaces/aces2065_1.py b/lib/coloraide/spaces/aces2065_1.py index 4b2d3cfc..2efc7007 100644 --- a/lib/coloraide/spaces/aces2065_1.py +++ b/lib/coloraide/spaces/aces2065_1.py @@ -3,11 +3,11 @@ https://www.oscars.org/science-technology/aces/aces-documentation """ +from __future__ import annotations from ..channels import Channel -from ..spaces.srgb import sRGB +from ..spaces.srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector -from typing import Tuple AP0_TO_XYZ = [ [0.9525523959381857, 0.0, 9.367863166046855e-05], @@ -28,21 +28,21 @@ def aces_to_xyz(aces: Vector) -> Vector: """Convert ACEScc to XYZ.""" - return alg.dot(AP0_TO_XYZ, aces, dims=alg.D2_D1) + return alg.matmul(AP0_TO_XYZ, aces, dims=alg.D2_D1) def xyz_to_aces(xyz: Vector) -> Vector: """Convert XYZ to ACEScc.""" - return alg.dot(XYZ_TO_AP0, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_AP0, xyz, dims=alg.D2_D1) -class ACES20651(sRGB): +class ACES20651(sRGBLinear): """The ACES color class.""" BASE = "xyz-d65" NAME = "aces2065-1" - SERIALIZE = ("--aces2065-1",) # type: Tuple[str, ...] + SERIALIZE = ("--aces2065-1",) WHITE = (0.32168, 0.33767) CHANNELS = ( Channel("r", 0.0, 65504.0, bound=True), diff --git a/lib/coloraide/spaces/acescc.py b/lib/coloraide/spaces/acescc.py index 3881ed33..a1bfc7af 100644 --- a/lib/coloraide/spaces/acescc.py +++ b/lib/coloraide/spaces/acescc.py @@ -3,11 +3,11 @@ https://www.oscars.org/science-technology/aces/aces-documentation """ +from __future__ import annotations import math from ..channels import Channel -from ..spaces.srgb import sRGB +from ..spaces.srgb_linear import sRGBLinear from ..types import Vector -from typing import Tuple CC_MIN = (math.log2(2 ** -16) + 9.72) / 17.52 CC_MAX = (math.log2(65504) + 9.72) / 17.52 @@ -50,12 +50,12 @@ def acescg_to_acescc(acescg: Vector) -> Vector: return acescc -class ACEScc(sRGB): +class ACEScc(sRGBLinear): """The ACEScc color class.""" BASE = "acescg" NAME = "acescc" - SERIALIZE = ("--acescc",) # type: Tuple[str, ...] + SERIALIZE = ("--acescc",) # type: tuple[str, ...] WHITE = (0.32168, 0.33767) CHANNELS = ( Channel("r", CC_MIN, CC_MAX, bound=True, nans=CC_MIN), @@ -64,6 +64,11 @@ class ACEScc(sRGB): ) DYNAMIC_RANGE = 'hdr' + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE + def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/acescct.py b/lib/coloraide/spaces/acescct.py index 83807808..5b0eb166 100644 --- a/lib/coloraide/spaces/acescct.py +++ b/lib/coloraide/spaces/acescct.py @@ -3,11 +3,11 @@ https://www.oscars.org/science-technology/aces/aces-documentation """ +from __future__ import annotations import math from ..channels import Channel -from ..spaces.srgb import sRGB +from ..spaces.srgb_linear import sRGBLinear from ..types import Vector -from typing import Tuple from .acescc import CC_MAX CCT_MIN = 0.0729055341958355 @@ -45,12 +45,12 @@ def acescg_to_acescct(acescg: Vector) -> Vector: return acescc -class ACEScct(sRGB): +class ACEScct(sRGBLinear): """The ACEScct color class.""" BASE = "acescg" NAME = "acescct" - SERIALIZE = ("--acescct",) # type: Tuple[str, ...] + SERIALIZE = ("--acescct",) # type: tuple[str, ...] WHITE = (0.32168, 0.33767) CHANNELS = ( Channel("r", CCT_MIN, CCT_MAX, bound=True, nans=CCT_MIN), @@ -59,6 +59,11 @@ class ACEScct(sRGB): ) DYNAMIC_RANGE = 'hdr' + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE + def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/acescg.py b/lib/coloraide/spaces/acescg.py index 65b857cd..ad6394dc 100644 --- a/lib/coloraide/spaces/acescg.py +++ b/lib/coloraide/spaces/acescg.py @@ -3,11 +3,11 @@ https://www.oscars.org/science-technology/aces/aces-documentation """ +from __future__ import annotations from ..channels import Channel -from ..spaces.srgb import sRGB +from ..spaces.srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector -from typing import Tuple AP1_TO_XYZ = [ [0.6624541811085053, 0.13400420645643313, 0.15618768700490782], @@ -25,21 +25,21 @@ def acescg_to_xyz(acescg: Vector) -> Vector: """Convert ACEScc to XYZ.""" - return alg.dot(AP1_TO_XYZ, acescg, dims=alg.D2_D1) + return alg.matmul(AP1_TO_XYZ, acescg, dims=alg.D2_D1) def xyz_to_acescg(xyz: Vector) -> Vector: """Convert XYZ to ACEScc.""" - return alg.dot(XYZ_TO_AP1, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_AP1, xyz, dims=alg.D2_D1) -class ACEScg(sRGB): +class ACEScg(sRGBLinear): """The ACEScg color class.""" BASE = "xyz-d65" NAME = "acescg" - SERIALIZE = ("--acescg",) # type: Tuple[str, ...] + SERIALIZE = ("--acescg",) # type: tuple[str, ...] WHITE = (0.32168, 0.33767) CHANNELS = ( Channel("r", 0.0, 65504.0, bound=True), diff --git a/lib/coloraide/spaces/achromatic.py b/lib/coloraide/spaces/achromatic.py deleted file mode 100644 index 679aeecf..00000000 --- a/lib/coloraide/spaces/achromatic.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tools for dynamic achromatic response.""" -from .. import algebra as alg -import bisect -from typing import Any -from ..types import Vector -from abc import ABCMeta, abstractmethod -import math -from typing import List, Tuple, Optional - - -class Achromatic(metaclass=ABCMeta): - """Calculate a spline that follows a color's achromatic response.""" - - L_IDX = 0 - C_IDX = 1 - H_IDX = 2 - - def __init__( - self, - data: Optional[List[Vector]] = None, - threshold_upper: float = alg.inf, - threshold_lower: float = alg.inf, - threshold_cutoff: float = alg.inf, - spline: str = 'linear', - mirror: bool = False, - **kwargs: Any - ) -> None: - """ - Initialize. - - `tuning`: Either a dictionary of `low`, `mid`, and `high`, each specifying a start, end, step, and scale - used to build a portion of the spline representing the achromatic response or a list containing the - the pre-calculated spline points. - `threshold_upper`: threshold above achromatic curve. - `threshold_lower`: threshold below achromatic curve. - `threshold_cutoff`: threshold of chroma above which we will assume colors are not achromatic. - `spline`: spline type. - `mirror`: Mirror response across lightness axis at zero. - """ - - self.mirror = mirror - self.threshold_upper = threshold_upper - self.threshold_lower = threshold_lower - self.threshold_cutoff = threshold_cutoff - - self.domain = [] # type: List[float] - self.min_colorfulness = 1e10 - self.min_lightness = 1e10 - self.spline_type = spline - - # Create a spline that maps the achromatic range for the SDR range - if data is not None: - self.setup_achromatic_response(data, **kwargs) - - def dump(self) -> Optional[List[Vector]]: # pragma: no cover - """Dump data points.""" - - if self.spline_type == 'linear': - return list(zip(*self.spline.points)) - else: - # Strip off the data points used to coerce the spline through the end. - return list(zip(*self.spline.points))[1:-1] - - @abstractmethod - def convert(self, coords: Vector, **kwargs: Any) -> Vector: - """Convert an sRGB color to the desired space.""" - - def calc_achromatic_response( - self, - parameters: List[Tuple[int, int, int, float]], - **kwargs: Any - ) -> None: # pragma: no cover - """ - Calculate the achromatic response. - - Used to precalculate the best response. - """ - - points = [] # type: List[List[float]] - for segment in parameters: - start, end, step, scale = segment - for p in range(start, end, step): - color = self.convert([p / scale] * 3, **kwargs) - l, c, h = color[self.L_IDX], color[self.C_IDX], color[self.H_IDX] - if l < self.min_lightness: - self.min_lightness = l - if c < self.min_colorfulness: - self.min_colorfulness = c - self.domain.append(l) - points.append([l, c, h % 360]) - self.spline = alg.interpolate(points, method=self.spline_type) - self.hue = self.convert([1] * 3, **kwargs)[self.H_IDX] % 360 - self.ihue = (self.hue - 180) % 360 - - def setup_achromatic_response( - self, - tuning: List[Vector], - **kwargs: Any - ) -> None: - """Setup the achromatic response.""" - - points = [] # type: List[List[float]] - for entry in tuning: - l, c, h = entry - if l < self.min_lightness: - self.min_lightness = l - if c < self.min_colorfulness: - self.min_colorfulness = c - points.append([l, c, h]) - self.domain.append(l) - self.spline = alg.interpolate(points, method=self.spline_type) - self.hue = self.convert([1] * 3, **kwargs)[self.H_IDX] % 360 - self.ihue = (self.hue - 180) % 360 - - def scale(self, point: float) -> float: - """Scale the lightness to match the range.""" - - if point <= self.domain[0]: - point = (point - self.domain[0]) / (self.domain[-1] - self.domain[0]) - elif point >= self.domain[-1]: - point = 1.0 + (point - self.domain[-1]) / (self.domain[-1] - self.domain[0]) - else: - regions = len(self.domain) - 1 - size = (1 / regions) - index = 0 - adjusted = 0.0 - index = bisect.bisect(self.domain, point) - 1 - a, b = self.domain[index:index + 2] - l = b - a - adjusted = ((point - a) / l) if l else 0.0 - point = size * index + (adjusted * size) - return point - - def get_ideal_chroma(self, l: float) -> float: - """Get the ideal chroma.""" - - if math.isnan(l): - return 0.0 - - elif self.mirror and l < 0.0: - return self.spline(self.scale(abs(l)))[1] - - return self.spline(self.scale(l))[1] - - def get_ideal_hue(self, l: float) -> float: - """Get the ideal chroma.""" - - if math.isnan(l): - return 0.0 - - elif self.mirror and l < 0.0: - return (self.spline(self.scale(abs(l)))[2] - 180) % 360 - - return self.spline(self.scale(l))[2] - - def get_ideal_ab(self, l: float) -> Tuple[float, float]: - """Get the ideal rectangular form of chroma and hue, the components a and b.""" - - if math.isnan(l): - return 0.0, 0.0 - - return alg.polar_to_rect(self.get_ideal_chroma(l), self.get_ideal_hue(l)) - - def test(self, l: float, c: float, h: float) -> bool: - """Test if the current color is achromatic.""" - - # If colorfulness is past this limit, we'd have to have a lightness - # so high, that our test has already broken down. - if c > self.threshold_cutoff or (not self.mirror and l < 0.0): - return False - - # If we are higher than 1, we are extrapolating; - # otherwise, use the spline. - flip = self.mirror and l < 0.0 - la = abs(l) - point = self.scale(la if flip else l) - if la < self.min_lightness and c < self.min_colorfulness: # pragma: no cover - return True - else: - c2, h2 = self.spline(point)[1:] - if flip: - h2 = (h2 - 180) % 360 - diff = c2 - c - hdiff = abs(h % 360 - h2) - if hdiff > 180: # pragma: no cover - hdiff = 360 - hdiff - return ( - ((diff >= 0 and diff < self.threshold_upper) or (diff < 0 and abs(diff) < self.threshold_lower)) and - (c2 < 1e-5 or hdiff < 0.01) - ) diff --git a/lib/coloraide/spaces/cam16.py b/lib/coloraide/spaces/cam16.py deleted file mode 100644 index 3e5a917e..00000000 --- a/lib/coloraide/spaces/cam16.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -CAM16 class. - -https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf -https://observablehq.com/@jrus/cam16 -https://arxiv.org/abs/1802.06067 -https://doi.org/10.1002/col.22131 -""" -import math -from .cam16_jmh import CAM16JMh -from ..spaces import Space, Labish -from ..cat import WHITES -from ..channels import Channel, FLG_MIRROR_PERCENT -from .. import util -from ..types import Vector -from .. import algebra as alg - - -def cam16_jmh_to_cam16_jab(jmh: Vector) -> Vector: - """Translate a CAM16 JMh to Jab of the same viewing conditions.""" - - J, M, h = jmh - return [ - J, - M * math.cos(math.radians(h)), - M * math.sin(math.radians(h)) - ] - - -def cam16_jab_to_cam16_jmh(jab: Vector) -> Vector: - """Translate a CAM16 Jab to JMh of the same viewing conditions.""" - - J, a, b = jab - M = math.sqrt(a ** 2 + b ** 2) - h = math.degrees(math.atan2(b, a)) - - return [J, M, util.constrain_hue(h)] - - -class CAM16(Labish, Space): - """CAM16 class (Jab).""" - - BASE = "cam16-jmh" - NAME = "cam16" - SERIALIZE = ("--cam16",) - CHANNELS = ( - Channel("j", 0.0, 100.0, limit=(0.0, None)), - Channel("a", -90.0, 90.0, flags=FLG_MIRROR_PERCENT), - Channel("b", -90.0, 90.0, flags=FLG_MIRROR_PERCENT) - ) - CHANNEL_ALIASES = { - "lightness": "j" - } - WHITE = WHITES['2deg']['D65'] - # Use the same environment as CAM16JMh - ENV = CAM16JMh.ENV - ACHROMATIC = CAM16JMh.ACHROMATIC - - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index in (1, 2): - if not math.isnan(coords[index]): - return coords[index] - - return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1] - - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value - - def is_achromatic(self, coords: Vector) -> bool: - """Check if color is achromatic.""" - - m, h = alg.rect_to_polar(coords[1], coords[2]) - return coords[0] == 0.0 or self.ACHROMATIC.test(coords[0], m, h) - - def to_base(self, coords: Vector) -> Vector: - """To CAM16 JMh from CAM16.""" - - return cam16_jab_to_cam16_jmh(coords) - - def from_base(self, coords: Vector) -> Vector: - """From CAM16 JMh to CAM16.""" - - return cam16_jmh_to_cam16_jab(coords) diff --git a/lib/coloraide/spaces/cam16_jmh.py b/lib/coloraide/spaces/cam16_jmh.py index fd63ac79..c1f2879a 100644 --- a/lib/coloraide/spaces/cam16_jmh.py +++ b/lib/coloraide/spaces/cam16_jmh.py @@ -1,31 +1,25 @@ """ CAM16 class (JMh). -https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf +https://www.researchgate.net/publication/318152296_Comprehensive_color_solutions_CAM16_CAT16_and_CAM16-UCS +https://www.researchgate.net/publication/220865484_Usage_guidelines_for_CIECAM97s +https://doi.org/10.1002/col.22131 https://observablehq.com/@jrus/cam16 https://arxiv.org/abs/1802.06067 -https://doi.org/10.1002/col.22131 """ +from __future__ import annotations import math import bisect from .. import util from .. import algebra as alg from ..spaces import Space, LChish -from ..cat import WHITES +from ..cat import WHITES, CAT16 from ..channels import Channel, FLG_ANGLE -from .achromatic import Achromatic as _Achromatic -from .srgb_linear import lin_srgb_to_xyz -from .srgb import lin_srgb +from .lch import ACHROMATIC_THRESHOLD from ..types import Vector, VectorLike -from typing import List, Tuple, Any, Optional # CAT16 -M16 = [ - [0.401288, 0.650173, -0.051461], - [-0.250268, 1.204414, 0.045854], - [-0.002079, 0.048952, 0.953127] -] - +M16 = CAT16.MATRIX MI6_INV = alg.inv(M16) M1 = [ @@ -50,172 +44,6 @@ "H": (0.0, 100.0, 200.0, 300.0, 400.0) } -ACHROMATIC_RESPONSE = [ - [0.5197637353103578, 0.10742101902546781, 209.53702017876432], - [0.7653410752577902, 0.14285252204838345, 209.53699586564701], - [0.9597225272459006, 0.1674639325209385, 209.5369779985614], - [1.1268800051398011, 0.18685008110835863, 209.5369633468102], - [1.2763267654963262, 0.2030639541700499, 209.53695070009815], - [1.8790377054651906, 0.26061543207726645, 209.53690294574977], - [2.3559753209404573, 0.2998668559412108, 209.53686786031403], - [2.7660324716814633, 0.3305058430743193, 209.536839093849], - [3.1325805945142164, 0.3559891904115732, 209.53681426762876], - [3.4678031173571573, 0.3779903818931045, 209.5367921887862], - [3.7790286254197216, 0.397459500662546, 209.53677216077287], - [4.071081036208187, 0.41499276041595823, 209.53675373607373], - [4.35987940988476, 0.4317047383578854, 209.53673583657556], - [4.653923368359191, 0.44814375233486375, 209.53671791170922], - [7.833618587426542, 0.5996289140723937, 209.53653852944092], - [11.379933581738413, 0.7341977581705729, 209.53635903741286], - [15.226965773377026, 0.857259036676519, 209.53617955384075], - [19.331425543357224, 0.9717687075452716, 209.53600014936998], - [23.662223781347393, 1.0795574256327514, 209.5358208710024], - [28.195712492409086, 1.181856050998015, 209.5356417521982], - [32.91316356733729, 1.2795417603936137, 209.53546281794857], - [37.799294675334956, 1.3732674055098613, 209.53528408756415], - [42.841343691205466, 1.4635354939944947, 209.5351055763844], - [48.028455187972774, 1.5507433232034882, 209.53492729681835], - [53.35125605180922, 1.6352119585004592, 209.53474925909882], - [58.80155160494701, 1.7172056265103606, 209.53457147176655], - [64.37210171522501, 1.7969451471063127, 209.53439394202456], - [70.05645182832635, 1.8746175094579138, 209.53421667601953], - [75.8488028092011, 1.95038286970608, 209.53403967901312], - [81.74390888832981, 2.0243797747219405, 209.5338629555596], - [87.73699639870503, 2.096729134902292, 209.53368650960735], - [93.82369818202439, 2.1675372954936383, 209.53351034459058], - [100.0, 2.2368984457705743, 209.53333446353417], - [106.26219627903436, 2.304896533525034, 209.53315886906827], - [112.60685320678104, 2.37160680430044, 209.5329835635175], - [119.03077768851209, 2.4370970520495017, 209.53280854892373], - [125.53099102420309, 2.501428645093027, 209.53263382708093], - [132.1047064258276, 2.5646573751376196, 209.532459399577], - [138.74930968646498, 2.626834165511834, 209.53228526779787], - [145.46234245739691, 2.6880056663241145, 209.5321114329661], - [152.24148769946316, 2.748214758003532, 209.5319378961533], - [159.08455695969053, 2.807500980008346, 209.53176465830455], - [165.98947919010237, 2.865900897958051, 209.53159172021918], - [172.95429087732893, 2.92344841973748, 209.53141908260915], - [179.9771272925688, 2.980175069050048, 209.53124674607443], - [187.05621470411134, 3.0361102232687336, 209.5310747111302], - [194.18986342088985, 3.0912813211607015, 209.53090297820904], - [201.3764615567875, 3.145714045061113, 209.5307315476577], - [208.6144694227416, 3.199432481261756, 209.53056041976922], - [215.90241446789472, 3.252459261745779, 209.5303895947692], - [223.23888670275286, 3.304815689873112, 209.53021907281482], - [230.62253454702395, 3.3565218522006455, 209.53004885402189], - [238.05206105290907, 3.4075967182791853, 209.5298789384579], - [245.52622046139462, 3.458058229985187, 209.52970932613115], - [253.04381505480683, 3.507923381705505, 209.52954001701696], - [260.6036922737095, 3.5572082925096393, 209.52937101105175], - [268.20474207032214, 3.605928271271525, 209.52920230813686], - [275.8458944741247, 3.654097875574637, 209.52903390813748], - [283.52611734829725, 3.7017309651178785, 209.52886581088453], - [291.2444143182101, 3.7488407502396903, 209.52869801618388], - [298.9998228553806, 3.795439836103318, 209.52853052381926], - [306.7914125022303, 3.841540263013781, 209.52836333353144], - [314.61828322461565, 3.8871535432701356, 209.52819644505152], - [322.479563880563, 3.9322906949207233, 209.52802985809194], - [330.37441079487513, 3.9769622727358835, 209.52786357233052], - [338.30200643038665, 4.021178396670769, 209.52769758743355], - [346.2615581476034, 4.064948778071774, 209.527531903052], - [354.2522970453064, 4.108282743840221, 209.52736651881136], - [362.2734768754491, 4.151189258746347, 209.52720143432805], - [370.32437302633326, 4.193676946068329, 209.52703664919864], - [378.40428156863675, 4.235754106704258, 209.52687216301322], - [386.5125183593773, 4.2774287369029045, 209.52670797533614], - [394.648418199364, 4.318708544722146, 209.5265440857335], - [402.8113340400954, 4.359600965341976, 209.52638049373465], - [411.00063623642933, 4.400113175309797, 209.52621719889768], - [419.2157118416773, 4.440252105831943, 209.52605420073294], - [427.45596394207547, 4.480024455165197, 209.52589149876238], - [435.7208110278373, 4.519436700202084, 209.52572909248678], - [444.00968639824373, 4.558495107300929, 209.52556698141063], - [452.32203759843054, 4.597205742430075, 209.52540516500954], - [460.65732588572826, 4.635574480668486, 209.52524364277534], - [469.01502572358646, 4.67360701512636, 209.52508241417655], - [477.39462430127537, 4.711308865315819, 209.52492147868603], - [485.79562107768834, 4.7486853850221635, 209.5247608357571], - [494.2175273477135, 4.78574176970701, 209.5246004848463], - [502.65986582975273, 4.822483063478878, 209.52444042540557], - [511.12217027307815, 4.858914165665327, 209.52428065686505], - [519.603985083808, 4.895039837005332, 209.52412117867837], - [528.1048649683819, 4.930864705501955, 209.52396199027478], - [536.6243745934894, 4.966393271948785, 209.52380309107085], - [545.1620882614892, 5.00162991514904, 209.52364448050218], - [553.7175896004151, 5.036578896863506, 209.52348615798303], - [562.2904712677357, 5.071244366487241, 209.52332812293528], - [570.8803346670916, 5.105630365484719, 209.52317037476547], - [579.4867896772784, 5.139740831594337, 209.5230129128839], - [588.1094543928118, 5.173579602815829, 209.5228557367024], - [596.7479548754266, 5.207150421200013, 209.52269884562236], - [605.4019249159434, 5.240456936444621, 209.52254223903395], - [614.0710058059254, 5.2735027093139655, 209.5223859163477], - [622.7548461186354, 5.306291214893902, 209.52222987695998], - [631.4531014987923, 5.338825845688801, 209.52207412025294], - [640.1654344606808, 5.371109914568736, 209.52191864562857], - [648.8915141941957, 5.403146657580616, 209.5217634524664], - [657.6310163784137, 5.434939236625219, 209.52160854017026], - [666.3836230023236, 5.466490742017722, 209.52145390811216], - [675.1490221923619, 5.497804194921784, 209.5212995556808], - [683.9269080464271, 5.528882549682189, 209.52114548225762], - [692.7169804740557, 5.559728696047723, 209.52099168723132], - [701.5189450424705, 5.590345461302892, 209.5208381699748], - [710.3325128282281, 5.620735612298451, 209.52068492987357], - [719.1574002741951, 5.6509018574012, 209.52053196630135], - [727.9933290516212, 5.6808468483554915, 209.52037927864566], - [736.8400259270659, 5.710573182071535, 209.52022686627654], - [745.6972226339658, 5.7400834023332274, 209.5200747285655], - [754.5646557486314, 5.769380001437249, 209.51992286490193], - [763.4420665704857, 5.79846542176912, 209.51977127465065], - [772.3292010063437, 5.827342057308235, 209.519619957194] -] # type: List[Vector] - - -class Achromatic(_Achromatic): - """ - Test if color is achromatic. - - Should work quite well through the SDR range. Can reasonably handle HDR range out to 3 - which is far enough for anything practical. - We use a spline mainly to quickly fit the line in a way we do not have to analyze and tune. - """ - - L_IDX = 0 - C_IDX = 1 - H_IDX = 2 - - def __init__( - self, - data: Optional[List[Vector]] = None, - threshold_upper: float = 0.0, - threshold_lower: float = 0.0, - threshold_cutoff: float = alg.inf, - spline: str = 'linear', - mirror: bool = False, - *, - env: 'Environment', - **kwargs: Any - ) -> None: - """Initialize.""" - - super().__init__(data, threshold_upper, threshold_lower, threshold_cutoff, spline, mirror, env=env, **kwargs) - - def calc_achromatic_response( # type: ignore[override] - self, - parameters: List[Tuple[int, int, int, float]], - *, - env: 'Environment', - **kwargs: Any - ) -> None: # pragma: no cover - """Precalculate the achromatic response.""" - - super().calc_achromatic_response(parameters, env=env, **kwargs) - - def convert(self, coords: Vector, *, env: 'Environment', **kwargs: Any) -> Vector: # type: ignore[override] - """Convert to the target color space.""" - - return xyz_d65_to_cam16_jmh(lin_srgb_to_xyz(lin_srgb(coords)), env) - def hue_quadrature(h: float) -> float: """ @@ -254,7 +82,7 @@ def adapt(coords: Vector, fl: float) -> Vector: adapted = [] for c in coords: - x = alg.npow(fl * c * 0.01, ADAPTED_COEF) + x = (fl * abs(c) * 0.01) ** ADAPTED_COEF adapted.append(400 * math.copysign(x, c) / (x + 27.13)) return adapted @@ -266,7 +94,7 @@ def unadapt(adapted: Vector, fl: float) -> Vector: constant = 100 / fl * (27.13 ** ADAPTED_COEF_INV) for c in adapted: cabs = abs(c) - coords.append(math.copysign(constant * alg.npow(cabs / (400 - cabs), ADAPTED_COEF_INV), c)) + coords.append(math.copysign(constant * alg.spow(cabs / (400 - cabs), ADAPTED_COEF_INV), c)) return coords @@ -275,16 +103,17 @@ class Environment: Class to calculate and contain any required environmental data (viewing conditions included). Usage Guidelines for CIECAM97s (Nathan Moroney) - https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf + https://www.researchgate.net/publication/220865484_Usage_guidelines_for_CIECAM97s - ref_white: The reference white XYZ. We assume XYZ is in the range 0 - 1 as that is how ColorAide - handles XYZ everywhere else. It will be scaled up to 0 - 100. + white: This is the (x, y) chromaticity points for the white point. This should be the same + value as set in the color class `WHITE` value. adapting_luminance: This is the the luminance of the adapting field. The units are in cd/m2. The equation is `L = (E * R) / π`, where `E` is the illuminance in lux, `R` is the reflectance, and `L` is the luminance. If we assume a perfectly reflecting diffuser, `R` is assumed as 1. For the "gray world" assumption, we must also divide by 5 (or multiply by 0.2 - 20%). - This results in `La = E / π * 0.2`. + This results in `La = E / π * 0.2`. You can also ignore this gray world assumption converting + lux directly to nits (cd/m2) `lux / π`. background_luminance: The background is the region immediately surrounding the stimulus and for images is the neighboring portion of the image. Generally, this value is set to a value of 20. @@ -303,7 +132,8 @@ class Environment: def __init__( self, - ref_white: VectorLike, + *, + white: VectorLike, adapting_luminance: float, background_luminance: float, surround: str, @@ -317,19 +147,19 @@ def __init__( """ self.discounting = discounting - self.ref_white = util.xy_to_xyz(ref_white) + self.ref_white = util.xy_to_xyz(white) self.surround = surround - xyz_w = alg.multiply(self.ref_white, 100, dims=alg.D1_SC) # The average luminance of the environment in `cd/m^2cd/m` (a.k.a. nits) self.la = adapting_luminance # The relative luminance of the nearby background self.yb = background_luminance # Absolute luminance of the reference white. + xyz_w = util.scale100(self.ref_white) yw = xyz_w[1] # Cone response for reference white - rgb_w = alg.dot(M16, xyz_w, dims=alg.D2_D1) + rgb_w = alg.matmul(M16, xyz_w, dims=alg.D2_D1) # Surround: dark, dim, and average f, self.c, self.nc = SURROUND[self.surround] @@ -358,14 +188,14 @@ def __init__( def cam16_to_xyz_d65( - J: Optional[float] = None, - C: Optional[float] = None, - h: Optional[float] = None, - s: Optional[float] = None, - Q: Optional[float] = None, - M: Optional[float] = None, - H: Optional[float] = None, - env: Optional[Environment] = None + J: float | None = None, + C: float | None = None, + h: float | None = None, + s: float | None = None, + Q: float | None = None, + M: float | None = None, + H: float | None = None, + env: Environment | None = None ) -> Vector: """ From CAM16 to XYZ. @@ -378,8 +208,6 @@ def cam16_to_xyz_d65( """ # These check ensure one, and only one attribute for a given category is provided. - # Unfortunately, `mypy` is not smart enough to tell which one is not `None`, - # so we have to we must test each again later, but it is still faster than calling `cast()`. if not ((J is not None) ^ (Q is not None)): raise ValueError("Conversion requires one and only one: 'J' or 'Q'") @@ -422,37 +250,33 @@ def cam16_to_xyz_d65( alpha = (M / env.fl_root) / J_root elif s is not None: alpha = 0.0004 * (s ** 2) * (env.a_w + 4) / env.c - t = alg.npow(alpha * math.pow(1.64 - math.pow(0.29, env.n), -0.73), 10 / 9) + t = alg.spow(alpha * math.pow(1.64 - math.pow(0.29, env.n), -0.73), 10 / 9) # Eccentricity et = 0.25 * (math.cos(h_rad + 2) + 3.8) # Achromatic response - A = env.a_w * alg.npow(J_root, 2 / env.c / env.z) + A = env.a_w * alg.spow(J_root, 2 / env.c / env.z) # Calculate red-green and yellow-blue components p1 = 5e4 / 13 * env.nc * env.ncb * et p2 = A / env.nbb - r = 23 * (p2 + 0.305) * t / (23 * p1 + t * (11 * cos_h + 108 * sin_h)) + r = 23 * (p2 + 0.305) * alg.zdiv(t, 23 * p1 + t * (11 * cos_h + 108 * sin_h)) a = r * cos_h b = r * sin_h # Calculate back from cone response to XYZ - rgb_c = unadapt(alg.multiply(alg.dot(M1, [p2, a, b], dims=alg.D2_D1), 1 / 1403, dims=alg.D1_SC), env.fl) - return alg.divide( - alg.dot(MI6_INV, alg.multiply(rgb_c, env.d_rgb_inv, dims=alg.D1), dims=alg.D2_D1), - 100, - dims=alg.D1_SC - ) + rgb_c = unadapt(alg.multiply(alg.matmul(M1, [p2, a, b], dims=alg.D2_D1), 1 / 1403, dims=alg.D1_SC), env.fl) + return util.scale1(alg.matmul(MI6_INV, alg.multiply(rgb_c, env.d_rgb_inv, dims=alg.D1), dims=alg.D2_D1)) -def xyz_d65_to_cam16(xyzd65: Vector, env: Environment) -> Vector: +def xyz_d65_to_cam16(xyzd65: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector: """From XYZ to CAM16.""" # Cone response rgb_a = adapt( alg.multiply( - alg.dot(M16, alg.multiply(xyzd65, 100, dims=alg.D1_SC), dims=alg.D2_D1), + alg.matmul(M16, util.scale100(xyzd65), dims=alg.D2_D1), env.d_rgb, dims=alg.D1 ), @@ -462,24 +286,24 @@ def xyz_d65_to_cam16(xyzd65: Vector, env: Environment) -> Vector: # Calculate hue from red-green and yellow-blue components a = rgb_a[0] + (-12 * rgb_a[1] + rgb_a[2]) / 11 b = (rgb_a[0] + rgb_a[1] - 2 * rgb_a[2]) / 9 - h_rad = math.atan2(b, a) % alg.tau + h_rad = math.atan2(b, a) % math.tau # Eccentricity et = 0.25 * (math.cos(h_rad + 2) + 3.8) t = ( - 5e4 / 13 * env.nc * env.ncb * et * math.sqrt(a ** 2 + b ** 2) / - (rgb_a[0] + rgb_a[1] + 1.05 * rgb_a[2] + 0.305) + 5e4 / 13 * env.nc * env.ncb * + alg.zdiv(et * math.sqrt(a ** 2 + b ** 2), rgb_a[0] + rgb_a[1] + 1.05 * rgb_a[2] + 0.305) ) - alpha = alg.npow(t, 0.9) * math.pow(1.64 - math.pow(0.29, env.n), 0.73) + alpha = alg.spow(t, 0.9) * math.pow(1.64 - math.pow(0.29, env.n), 0.73) # Achromatic response A = env.nbb * (2 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2]) - J_root = alg.npow(A / env.a_w, 0.5 * env.c * env.z) + J_root = alg.spow(A / env.a_w, 0.5 * env.c * env.z) # Lightness - J = 100 * alg.npow(J_root, 2) + J = 100 * alg.spow(J_root, 2) # Brightness Q = (4 / env.c * J_root * (env.a_w + 4) * env.fl_root) @@ -494,7 +318,7 @@ def xyz_d65_to_cam16(xyzd65: Vector, env: Environment) -> Vector: h = util.constrain_hue(math.degrees(h_rad)) # Hue quadrature - H = hue_quadrature(h) + H = hue_quadrature(h) if calc_hue_quadrature else alg.NaN # Saturation s = 50 * alg.nth_root(env.c * alpha / (env.a_w + 4), 2) @@ -507,7 +331,7 @@ def xyz_d65_to_cam16_jmh(xyzd65: Vector, env: Environment) -> Vector: cam16 = xyz_d65_to_cam16(xyzd65, env) J, M, h = cam16[0], cam16[5], cam16[2] - return [max(0.0, J), max(0.0, M), h] + return [J, M, h] def cam16_jmh_to_xyz_d65(jmh: Vector, env: Environment) -> Vector: @@ -529,47 +353,39 @@ class CAM16JMh(LChish, Space): "hue": 'h' } WHITE = WHITES['2deg']['D65'] - # Assuming sRGB which has a lux of 64 - ENV = Environment(WHITE, 64 / math.pi * 0.2, 20, 'average', False) - # If discounting were True, we could remove the dynamic achromatic response - ACHROMATIC = Achromatic( - # Precalculated from: - # [ - # (1, 5, 1, 1000.0), - # (1, 10, 1, 200.0), - # (5, 521, 5, 100.0) - # ], - ACHROMATIC_RESPONSE, - 0.0012, - 0.0141, - 6.7, - 'catrom', - env=ENV + # Assuming sRGB which has a lux of 64: `((E * R) / PI) / 5` where `R = 1`. + ENV = Environment( + # Our white point. + white=WHITE, + # Assuming sRGB which has a lux of 64: `((E * R) / PI)` where `R = 1`. + # Divided by 5 (or multiplied by 20%) assuming gray world. + adapting_luminance=64 / math.pi * 0.2, + # Gray world assumption, 20% of reference white's `Yw = 100`. + background_luminance=20, + # Average surround + surround='average', + # Do not discount illuminant + discounting=False ) CHANNELS = ( - Channel("j", 0.0, 100.0, limit=(0.0, None)), - Channel("m", 0, 105.0, limit=(0.0, None)), - Channel("h", 0.0, 360.0, flags=FLG_ANGLE, nans=ACHROMATIC.hue) + Channel("j", 0.0, 100.0), + Channel("m", 0, 105.0), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index == 2: - h = coords[2] - return self.ACHROMATIC.get_ideal_hue(coords[0]) if math.isnan(h) else h - - elif index == 1: - c = coords[1] - return self.ACHROMATIC.get_ideal_chroma(coords[0]) if math.isnan(c) else c + def normalize(self, coords: Vector) -> Vector: + """Normalize.""" - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value + if coords[1] < 0.0: + return self.from_base(self.to_base(coords)) + coords[2] %= 360.0 + return coords - def is_achromatic(self, coords: Vector) -> bool: + def is_achromatic(self, coords: Vector) -> bool | None: """Check if color is achromatic.""" - return coords[0] == 0.0 or self.ACHROMATIC.test(coords[0], coords[1], coords[2]) + # Account for both positive and negative chroma + return coords[0] == 0 or abs(coords[1]) < ACHROMATIC_THRESHOLD def to_base(self, coords: Vector) -> Vector: """From CAM16 JMh to XYZ.""" diff --git a/lib/coloraide/spaces/cam16_ucs.py b/lib/coloraide/spaces/cam16_ucs.py index 806408ca..d5830859 100644 --- a/lib/coloraide/spaces/cam16_ucs.py +++ b/lib/coloraide/spaces/cam16_ucs.py @@ -5,11 +5,15 @@ https://arxiv.org/abs/1802.06067 https://doi.org/10.1002/col.22131 """ +from __future__ import annotations import math -from . cam16 import CAM16 -from ..types import Vector +from .cam16_jmh import CAM16JMh, xyz_d65_to_cam16, cam16_to_xyz_d65, Environment +from ..spaces import Space, Labish +from .lch import ACHROMATIC_THRESHOLD +from ..cat import WHITES +from .. import util from ..channels import Channel, FLG_MIRROR_PERCENT -from .. import algebra as alg +from ..types import Vector COEFFICENTS = { 'lcd': (0.77, 0.007, 0.0053), @@ -18,7 +22,7 @@ } -def cam16_to_cam16_ucs(jab: Vector, model: str) -> Vector: +def cam16_jmh_to_cam16_ucs(jmh: Vector, model: str, env: Environment) -> Vector: """ CAM16 (Jab) to CAM16 UCS (Jab). @@ -26,26 +30,34 @@ def cam16_to_cam16_ucs(jab: Vector, model: str) -> Vector: and then adding the new adjusted multiplier. Then we can just adjust lightness. """ - J, a, b = jab - M = math.sqrt(a ** 2 + b ** 2) + J, M, h = jmh + + if J == 0.0: + return [0.0, 0.0, 0.0] + + # Account for negative colorfulness by reconverting + if M < 0: + cam16 = xyz_d65_to_cam16(cam16_to_xyz_d65(J=J, M=M, h=h, env=env), env=env) + J, M, h = cam16[0], cam16[5], cam16[2] c1, c2 = COEFFICENTS[model][1:] - if M != 0: - a /= M - b /= M - M = math.log(1 + c2 * M) / c2 - a *= M - b *= M + # Only in extreme cases (outside the visible spectrum) + # can the input value for log become negative. + # Avoid domain error by forcing zero. + M = math.log(max(1 + c2 * M, 1.0)) / c2 + a = M * math.cos(math.radians(h)) + b = M * math.sin(math.radians(h)) + absj = abs(J) return [ - (1 + 100 * c1) * J / (1 + c1 * J), + math.copysign((1 + 100 * c1) * absj / (1 + c1 * absj), J), a, b ] -def cam16_ucs_to_cam16(ucs: Vector, model: str) -> Vector: +def cam16_ucs_to_cam16_jmh(ucs: Vector, model: str) -> Vector: """ CAM16 UCS (Jab) to CAM16 (Jab). @@ -54,53 +66,58 @@ def cam16_ucs_to_cam16(ucs: Vector, model: str) -> Vector: """ J, a, b = ucs - M = math.sqrt(a ** 2 + b ** 2) + + if J == 0.0: + return [0.0, 0.0, 0.0] c1, c2 = COEFFICENTS[model][1:] - if M != 0: - a /= M - b /= M - M = (math.exp(M * c2) - 1) / c2 - a *= M - b *= M + M = math.sqrt(a ** 2 + b ** 2) + M = (math.exp(M * c2) - 1) / c2 + h = math.degrees(math.atan2(b, a)) + absj = abs(J) return [ - J / (1 - c1 * (J - 100)), - a, - b + math.copysign(absj / (1 - c1 * (absj - 100)), J), + M, + util.constrain_hue(h) ] -class CAM16UCS(CAM16): +class CAM16UCS(Labish, Space): """CAM16 UCS (Jab) class.""" - BASE = "cam16" + BASE = "cam16-jmh" NAME = "cam16-ucs" SERIALIZE = ("--cam16-ucs",) MODEL = 'ucs' CHANNELS = ( - Channel("j", 0.0, 100.0, limit=(0.0, None)), + Channel("j", 0.0, 100.0), Channel("a", -50.0, 50.0, flags=FLG_MIRROR_PERCENT), Channel("b", -50.0, 50.0, flags=FLG_MIRROR_PERCENT) ) + CHANNEL_ALIASES = { + "lightness": "j" + } + WHITE = WHITES['2deg']['D65'] + # Use the same environment as CAM16JMh + ENV = CAM16JMh.ENV def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" - j, a, b = cam16_ucs_to_cam16(coords, self.MODEL) - m, h = alg.rect_to_polar(a, b) - return coords[0] == 0.0 or self.ACHROMATIC.test(j, m, h) + j, m = cam16_ucs_to_cam16_jmh(coords, self.MODEL)[:-1] + return j == 0 or abs(m) < ACHROMATIC_THRESHOLD def to_base(self, coords: Vector) -> Vector: - """To XYZ from CAM16.""" + """To CAM16 JMh from CAM16.""" - return cam16_ucs_to_cam16(coords, self.MODEL) + return cam16_ucs_to_cam16_jmh(coords, self.MODEL) def from_base(self, coords: Vector) -> Vector: - """From XYZ to CAM16.""" + """From CAM16 JMh to CAM16.""" - return cam16_to_cam16_ucs(coords, self.MODEL) + return cam16_jmh_to_cam16_ucs(coords, self.MODEL, self.ENV) class CAM16LCD(CAM16UCS): @@ -110,7 +127,7 @@ class CAM16LCD(CAM16UCS): SERIALIZE = ("--cam16-lcd",) MODEL = 'lcd' CHANNELS = ( - Channel("j", 0.0, 100.0, limit=(0.0, None)), + Channel("j", 0.0, 100.0), Channel("a", -70.0, 70.0, flags=FLG_MIRROR_PERCENT), Channel("b", -70.0, 70.0, flags=FLG_MIRROR_PERCENT) ) @@ -123,7 +140,7 @@ class CAM16SCD(CAM16UCS): SERIALIZE = ("--cam16-scd",) MODEL = 'scd' CHANNELS = ( - Channel("j", 0.0, 100.0, limit=(0.0, None)), + Channel("j", 0.0, 100.0), Channel("a", -40.0, 40.0, flags=FLG_MIRROR_PERCENT), Channel("b", -40.0, 40.0, flags=FLG_MIRROR_PERCENT) ) diff --git a/lib/coloraide/spaces/cmy.py b/lib/coloraide/spaces/cmy.py index 563e5468..0101502e 100644 --- a/lib/coloraide/spaces/cmy.py +++ b/lib/coloraide/spaces/cmy.py @@ -1,9 +1,9 @@ """Uncalibrated, naive CMY color space.""" -from ..spaces import Space +from __future__ import annotations +from ..spaces import Regular, Space from ..channels import Channel from ..cat import WHITES from ..types import Vector -from typing import Tuple from .. import algebra as alg import math @@ -20,12 +20,12 @@ def cmy_to_srgb(cmy: Vector) -> Vector: return [1 - c for c in cmy] -class CMY(Space): +class CMY(Regular, Space): """The CMY color class.""" BASE = "srgb" NAME = "cmy" - SERIALIZE = ("--cmy",) # type: Tuple[str, ...] + SERIALIZE = ("--cmy",) # type: tuple[str, ...] CHANNELS = ( Channel("c", 0.0, 1.0, bound=True), Channel("m", 0.0, 1.0, bound=True), @@ -43,7 +43,7 @@ def is_achromatic(self, coords: Vector) -> bool: black = [1, 1, 1] for x in alg.vcross(coords, black): - if not alg.isclose(0.0, x, abs_tol=1e-4, dims=algs.SC): + if not math.isclose(0.0, x, abs_tol=1e-4): return False return True diff --git a/lib/coloraide/spaces/cmyk.py b/lib/coloraide/spaces/cmyk.py index 1cdd1506..066f6b7e 100644 --- a/lib/coloraide/spaces/cmyk.py +++ b/lib/coloraide/spaces/cmyk.py @@ -3,11 +3,11 @@ https://www.w3.org/TR/css-color-5/#cmyk-rgb """ +from __future__ import annotations from ..spaces import Space from ..channels import Channel from ..cat import WHITES from ..types import Vector -from typing import Tuple from .. import algebra as alg import math @@ -42,7 +42,7 @@ class CMYK(Space): BASE = "srgb" NAME = "cmyk" - SERIALIZE = ("--cmyk",) # type: Tuple[str, ...] + SERIALIZE = ("--cmyk",) # type: tuple[str, ...] CHANNELS = ( Channel("c", 0.0, 1.0, bound=True), Channel("m", 0.0, 1.0, bound=True), @@ -60,12 +60,12 @@ class CMYK(Space): def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" - if alg.isclose(1.0, coords[-1], abs_tol=1e-4, dims=alg.SC): + if math.isclose(1.0, coords[-1], abs_tol=1e-4): return True black = [1, 1, 1] for x in alg.vcross(coords[:-1], black): - if not alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): + if not math.isclose(0.0, x, abs_tol=1e-5): return False return True diff --git a/lib/coloraide/spaces/cubehelix.py b/lib/coloraide/spaces/cubehelix.py index be5de32c..ad0a300d 100644 --- a/lib/coloraide/spaces/cubehelix.py +++ b/lib/coloraide/spaces/cubehelix.py @@ -23,6 +23,7 @@ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. """ +from __future__ import annotations from ..spaces import Space, HSLish from ..cat import WHITES from ..channels import Channel, FLG_ANGLE @@ -78,7 +79,7 @@ class Cubehelix(HSLish, Space): NAME = "cubehelix" SERIALIZE = ("--cubehelix",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, MAX_SAT, bound=True), Channel("l", 0.0, 1.0, bound=True) ) @@ -88,6 +89,16 @@ class Cubehelix(HSLish, Space): "lightness": "l" } WHITE = WHITES['2deg']['D65'] + GAMUT_CHECK = 'srgb' + + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + coords[1] *= -1.0 + coords[0] += 180.0 + coords[0] %= 360.0 + return coords def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" diff --git a/lib/coloraide/spaces/din99o.py b/lib/coloraide/spaces/din99o.py index 58742778..722748a6 100644 --- a/lib/coloraide/spaces/din99o.py +++ b/lib/coloraide/spaces/din99o.py @@ -3,6 +3,8 @@ https://de.wikipedia.org/wiki/DIN99-Farbraum """ +from __future__ import annotations +import sys from ..cat import WHITES from .lab import Lab import math @@ -32,14 +34,14 @@ C2 = 0.0039 C3 = 0.075 C4 = 0.0435 +MIN_FLOAT = sys.float_info.min def lab_to_din99o(lab: Vector) -> Vector: """XYZ to DIN99o.""" l, a, b = lab - val = 1 + abs(C2 * l) - l99o = C1 * math.copysign(1, l) * math.log(val) / KE + l99o = C1 * math.log(max(1 + C2 * l, MIN_FLOAT)) / KE if a == 0 and b == 0: a99o = b99o = 0.0 @@ -47,8 +49,7 @@ def lab_to_din99o(lab: Vector) -> Vector: eo = a * math.cos(RADS) + b * math.sin(RADS) fo = FACTOR * (b * math.cos(RADS) - a * math.sin(RADS)) go = math.sqrt(eo ** 2 + fo ** 2) - val = 1 + C3 * go - c99o = math.log(val) / (C4 * KE * KCH) + c99o = math.log(max(1 + C3 * go, MIN_FLOAT)) / (C4 * KE * KCH) h99o = math.atan2(fo, eo) + RADS a99o = c99o * math.cos(h99o) @@ -81,7 +82,7 @@ def din99o_to_lab(din99o: Vector) -> Vector: f = g * math.sin(h99o - RADS) return [ - math.copysign(1, l99o) * (math.exp((abs(l99o) * KE) / C1) - 1) / C2, + (math.exp(l99o * KE / C1) - 1) / C2, e * math.cos(RADS) - (f / FACTOR) * math.sin(RADS), e * math.sin(RADS) + (f / FACTOR) * math.cos(RADS) ] diff --git a/lib/coloraide/spaces/display_p3.py b/lib/coloraide/spaces/display_p3.py index b0cb3c3b..42fe41b9 100644 --- a/lib/coloraide/spaces/display_p3.py +++ b/lib/coloraide/spaces/display_p3.py @@ -1,15 +1,20 @@ """Display-p3 color class.""" -from ..cat import WHITES -from .srgb import sRGB, lin_srgb, gam_srgb +from __future__ import annotations +from .srgb_linear import sRGBLinear +from .srgb import lin_srgb, gam_srgb from ..types import Vector -class DisplayP3(sRGB): +class DisplayP3(sRGBLinear): """Display-p3 class.""" BASE = "display-p3-linear" NAME = "display-p3" - WHITE = WHITES['2deg']['D65'] + + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE def to_base(self, coords: Vector) -> Vector: """To XYZ from Display P3.""" diff --git a/lib/coloraide/spaces/display_p3_linear.py b/lib/coloraide/spaces/display_p3_linear.py index 2af6e387..90f1679d 100644 --- a/lib/coloraide/spaces/display_p3_linear.py +++ b/lib/coloraide/spaces/display_p3_linear.py @@ -1,6 +1,6 @@ """Linear Display-p3 color class.""" -from ..cat import WHITES -from .srgb import sRGB +from __future__ import annotations +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -25,22 +25,21 @@ def lin_p3_to_xyz(rgb: Vector) -> Vector: """ # 0 was computed as -3.972075516933488e-17 - return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_p3(xyz: Vector) -> Vector: """Convert XYZ to linear-light P3.""" - return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) -class DisplayP3Linear(sRGB): +class DisplayP3Linear(sRGBLinear): """Linear Display-p3 class.""" BASE = "xyz-d65" NAME = "display-p3-linear" SERIALIZE = ('--display-p3-linear',) - WHITE = WHITES['2deg']['D65'] def to_base(self, coords: Vector) -> Vector: """To XYZ from Linear Display P3.""" diff --git a/lib/coloraide/spaces/hct.py b/lib/coloraide/spaces/hct.py index a312cdeb..c86d2c58 100644 --- a/lib/coloraide/spaces/hct.py +++ b/lib/coloraide/spaces/hct.py @@ -6,12 +6,12 @@ Environment settings are calculated with the assumption of L* 50. As ColorAide usually cares about setting powerless hues as NaN, especially for good interpolation, -we've also calculated the cut off for chromatic colors and will properly enforce achromatic,powerless +we've also calculated the cut off for chromatic colors and will properly enforce achromatic, powerless hues. This is because CAM16 actually resolves colors as achromatic before chroma reaches zero as lightness increases. In the SDR range, a Tone of 100 will have a cut off as high as ~2.87 chroma. -Generally, the HCT color space is restricted to SDR range in the Material library, but we do not have -such restrictions. +Generally, the HCT color space is restricted to sRGB and SDR range in the Material library, but we do +not have such restrictions. Though we did not port HCT from Material Color Utilities, we did test against it, and are pretty much on point. The only differences are due to matrix precision and white point precision. Material @@ -40,113 +40,46 @@ color(--hct 256.79 31.766 33.344 / 1) ``` -Differences are inconsequential. """ +from __future__ import annotations from .. import algebra as alg -from .. import util from ..spaces import Space, LChish from ..cat import WHITES from ..channels import Channel, FLG_ANGLE from .cam16_jmh import Environment, cam16_to_xyz_d65, xyz_d65_to_cam16 -from .cam16_jmh import Achromatic as _Achromatic -from .srgb_linear import lin_srgb_to_xyz -from .srgb import lin_srgb from .lab import EPSILON, KAPPA, KE -from ..types import Vector, VectorLike -from typing import Any, List, Tuple +from .lch import ACHROMATIC_THRESHOLD +from ..types import Vector import math -ACHROMATIC_RESPONSE = [ - [0.06991457401674239, 0.14894004649491988, 209.54685746644427], - [0.13982914803348123, 0.19600973408107872, 209.54683151375164], - [0.20974372205022362, 0.22847588408233888, 209.54681244250324], - [0.279658296066966, 0.2539510473857674, 209.54679680373425], - [0.34957287008370486, 0.27520353123074354, 209.5467833054029], - [0.6991457401674133, 0.3503388633000292, 209.54673233806605], - [1.0487186102511181, 0.40138459372769575, 209.54669489478562], - [1.398291480334823, 0.44114839784486665, 209.54666419687067], - [1.7478643504185314, 0.47417655352389076, 209.54663770502296], - [2.0974372205022362, 0.5026634742613167, 209.54661414586045], - [2.447010090585941, 0.5278524887930993, 209.54659277583056], - [2.7965829606696495, 0.5505226723840699, 209.54657311722647], - [3.1624547958278697, 0.5721193279495385, 209.54655401960494], - [3.5553195764898433, 0.5933528830664249, 209.5465348955287], - [3.9752712774319967, 0.6142212187430165, 209.54651576862292], - [4.422818762249047, 0.6347462660662626, 209.54649663922487], - [4.89845719045681, 0.6549477974516252, 209.54647750762507], - [5.40266895987223, 0.6748437111130419, 209.54645837410766], - [5.93592454797767, 0.6944502696114526, 209.54643923893252], - [6.4986832666748775, 0.7137823011126716, 209.5464201023271], - [7.091393942308237, 0.7328533701575941, 209.54640096452033], - [7.714495530830426, 0.7516759233486977, 209.54638182570906], - [8.362902589291522, 0.7702614142702453, 209.54636268608778], - [9.010442756551821, 0.7886204111202876, 209.54634354582498], - [9.653817900540862, 0.8067626898669606, 209.5463244050959], - [10.29318377392433, 0.82469731522517, 209.5463052640513], - [10.928685772528809, 0.8424327113314979, 209.5462861228275], - [11.56045990779819, 0.859976723665597, 209.54626698158054], - [12.188633662809302, 0.8773366735026917, 209.5462478404262], - [12.813326748632115, 0.8945194059620538, 209.54622869949029], - [13.434651775012771, 0.9115313325460387, 209.54620955888788], - [14.0527148470809, 0.9283784689196849, 209.54619041872456], - [14.667616097925809, 0.9450664685622591, 209.54617127911737], - [15.279450165363095, 0.9616006528304665, 209.54615214014538], - [15.888306619956744, 0.977986037883613, 209.54613300191994], - [16.494270350320917, 0.9942273588674444, 209.54611386451924], - [17.097421910858294, 1.0103290916839158, 209.54609472803028], - [17.697837836366574, 1.026295472637739, 209.5460755925407], - [18.295590927334914, 1.042130516206454, 209.54605645811773], - [18.890750509238096, 1.0578380311468378, 209.54603732484748], - [19.483382668700443, 1.0734216351263122, 209.54601819278727], - [20.073550469030984, 1.088884768038463, 209.54599906201673], - [20.661314147315657, 1.1042307041468111, 209.54597993259077], - [53.38896474111432, 1.8932913045660376, 209.54481718319346], - [100.0, 2.871588955286566, 209.54293597883415], - [142.21233355267947, 3.6598615904431204, 209.54109066792404], - [181.74498695762142, 4.333037532847627, 209.53928262365196], - [219.37955914284302, 4.924275900757143, 209.5375118768831], - [255.55949550426857, 5.452264905961894, 209.53577795810992], - [290.56862469201246, 5.929002120236851, 209.534080169679], - [324.6031878228979, 6.362852583853973, 209.5324177017366], - [357.8063946931686, 6.7599912702266085, 209.53078968985596], - [390.2870215828255, 7.125170758147506, 209.52919524657287], - [422.13028305191517, 7.462166817890066, 209.52763347977915] -] # type: List[Vector] - - -def y_to_lstar(y: float, white: VectorLike) -> float: + +def y_to_lstar(y: float) -> float: """Convert XYZ Y to Lab L*.""" - y = y / white[1] fy = alg.nth_root(y, 3) if y > EPSILON else (KAPPA * y + 16) / 116 return (116.0 * fy) - 16.0 -def lstar_to_y(lstar: float, white: VectorLike) -> float: +def lstar_to_y(lstar: float) -> float: """Convert Lab L* to XYZ Y.""" fy = (lstar + 16) / 116 y = fy ** 3 if lstar > KE else lstar / KAPPA - return y * white[1] + return y def hct_to_xyz(coords: Vector, env: Environment) -> Vector: """ Convert HCT to XYZ. - Use Newton Raphson method to try and converge as quick as possible or - converge as close as we can. If we don't converge in about 7 iterations, - we will instead correct the Y in XYZ and re-calculate the J. This will - incrementally get our J closer. If we do not converge, we will do a final - round with Newton Raphson one last time with a more accurate J. - - If, for whatever reason, we cannot achieve the accuracy we seek in the - allotted iterations, just return the closest we were able to get. + Use Newton's method to try and converge as quick as possible or converge as + close as we can. While the requested precision is achieved most of the time, + it may not always be achievable. Especially past the visible spectrum, the + algorithm will likely struggle to get the same precision. If, for whatever + reason, we cannot achieve the accuracy we seek in the allotted iterations, + just return the closest we were able to get. """ - # Threshold of how close is close enough - threshold = 2e-8 - h, c, t = coords[:] # Shortcut out for black @@ -154,23 +87,29 @@ def hct_to_xyz(coords: Vector, env: Environment) -> Vector: return [0.0, 0.0, 0.0] # Calculate the Y we need to target - y = lstar_to_y(t, env.ref_white) + y = lstar_to_y(t) # Try to start with a reasonable initial guess for J - if c < 142: - # Calculated by curve fitting J vs T. Works well with colors within a mid-sized gamut, but not ultra wide. - j = 0.00462403 * t ** 2 + 0.51460278 * t + 2.62845677 + # Calculated by curve fitting J vs T. + if t > 0: + j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233 else: - # For ultra wide gamuts we can get a better J by correcting Y in XYZ and then calculating our J - xyz = cam16_to_xyz_d65(J=t, C=c, h=h, env=env) - xyz[1] = y - j = xyz_d65_to_cam16(xyz, env)[0] + j = 9.514440756550361e-06 * t ** 2 + 0.08693057439788597 * t -21.928975842194614 + + # Threshold of how close is close enough, and max number of attempts. + # More precision and more attempts means more time spent iterating. + # Higher required precision gives more accuracy but also increases the + # chance of not hitting the goal. 2e-12 allows us to convert round trip + # with reasonable accuracy of six decimal places or more. + threshold = 2e-12 + max_attempt = 15 - # Try to find a J such that the returned y matches the returned y of the L* attempt = 0 - last = alg.inf + last = math.inf best = j - while attempt < 16: + + # Try to find a J such that the returned y matches the returned y of the L* + while attempt <= max_attempt: xyz = cam16_to_xyz_d65(J=j, C=c, h=h, env=env) # If we are within range, return XYZ @@ -182,21 +121,14 @@ def hct_to_xyz(coords: Vector, env: Environment) -> Vector: best = j last = delta - # Use Newton Raphson method to see if we can quickly converge (or get as close as we can) - if (attempt < 7 or attempt >= 13) and xyz[1] != 0: - # ``` - # f(j_root) = (j ** (1 / 2)) * 0.1 - # f(j) = ((f(j_root) * 100) ** 2) / j - 1 = 0 - # f(j_root) = Y = y / 100 - # f(j) = (y ** 2) / j - 1 - # f'(j) = (2 * y) / j - # ``` - j = j - (xyz[1] - y) * j / (2 * xyz[1]) - - # Correct the lightness in XYZ and then re-calculate J - else: - xyz[1] = y - j = xyz_d65_to_cam16(xyz, env)[0] + # ``` + # f(j_root) = (j ** (1 / 2)) * 0.1 + # f(j) = ((f(j_root) * 100) ** 2) / j - 1 = 0 + # f(j_root) = Y = y / 100 + # f(j) = (y ** 2) / j - 1 + # f'(j) = (2 * y) / j + # ``` + j = j - (xyz[1] - y) * j / (2 * xyz[1]) attempt += 1 @@ -207,23 +139,11 @@ def hct_to_xyz(coords: Vector, env: Environment) -> Vector: def xyz_to_hct(coords: Vector, env: Environment) -> Vector: """Convert XYZ to HCT.""" - t = y_to_lstar(coords[1], env.ref_white) + t = y_to_lstar(coords[1]) + if t == 0.0: + return [0.0, 0.0, 0.0] c, h = xyz_d65_to_cam16(coords, env)[1:3] - return [h, max(0.0, c), max(0.0, t)] - - -class Achromatic(_Achromatic): - """Test HCT achromatic response.""" - - # Lightness and chroma (equivalent) index. - L_IDX = 2 - C_IDX = 1 - H_IDX = 0 - - def convert(self, coords: Vector, *, env: Environment, **kwargs: Any) -> Vector: # type: ignore[override] - """Convert to the target color space.""" - - return xyz_to_hct(lin_srgb_to_xyz(lin_srgb(coords)), env) + return [h, c, t] class HCT(LChish, Space): @@ -234,11 +154,16 @@ class HCT(LChish, Space): SERIALIZE = ("--hct",) WHITE = WHITES['2deg']['D65'] ENV = Environment( - WHITE, - 200 / math.pi * lstar_to_y(50.0, util.xy_to_xyz(WHITE)), - lstar_to_y(50.0, util.xy_to_xyz(WHITE)) * 100, - 'average', - False + # D65 white point. + white=WHITE, + # 200 lux or `~11.72 cd/m2` multiplied by ~18.42%, a variation of gray world assumption. + adapting_luminance=200 / math.pi * lstar_to_y(50.0), + # A variation on gray world assumption: ~18.42% of reference white's `Yw == 100`. + background_luminance=lstar_to_y(50.0) * 100, + # Average surround. + surround='average', + # No discounting of illuminant. + discounting=False ) CHANNEL_ALIASES = { "lightness": "t", @@ -247,48 +172,27 @@ class HCT(LChish, Space): "hue": "h" } - # Achromatic detection - # Precalculated from: - # [ - # (1, 5, 1, 1000.0), - # (1, 40, 1, 200.0), - # (50, 551, 50, 100.0) - # ] - ACHROMATIC = Achromatic( - ACHROMATIC_RESPONSE, - 0.0097, - 0.0787, - 8.1, - 'catrom', - env=ENV, - ) - CHANNELS = ( Channel("h", 0.0, 360.0, flags=FLG_ANGLE), - Channel("c", 0.0, 145.0, limit=(0.0, None)), - Channel("t", 0.0, 100.0, limit=(0.0, None)) + Channel("c", 0.0, 145.0), + Channel("t", 0.0, 100.0) ) - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index == 0: - h = coords[0] - return self.ACHROMATIC.get_ideal_hue(coords[2]) if math.isnan(h) else h - - elif index == 1: - c = coords[1] - return self.ACHROMATIC.get_ideal_chroma(coords[0]) if math.isnan(c) else c + def normalize(self, coords: Vector) -> Vector: + """Normalize.""" - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value + if coords[1] < 0.0: + return self.from_base(self.to_base(coords)) + coords[0] %= 360.0 + return coords - def is_achromatic(self, coords: Vector) -> bool: + def is_achromatic(self, coords: Vector) -> bool | None: """Check if color is achromatic.""" - return coords[2] == 0.0 or self.ACHROMATIC.test(coords[2], coords[1], coords[0]) + # Account for both positive and negative chroma + return coords[2] == 0 or abs(coords[1]) < ACHROMATIC_THRESHOLD - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return LCh-ish names in the order L C h.""" channels = self.channels diff --git a/lib/coloraide/spaces/hpluv.py b/lib/coloraide/spaces/hpluv.py index 157c0fb5..c0029285 100644 --- a/lib/coloraide/spaces/hpluv.py +++ b/lib/coloraide/spaces/hpluv.py @@ -24,24 +24,25 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from ..spaces import Space, HSLish from ..cat import WHITES from ..channels import Channel, FLG_ANGLE from .lab import EPSILON, KAPPA from .srgb_linear import XYZ_TO_RGB import math +from .. import algebra as alg from .. import util from ..types import Vector -from typing import Tuple, List -def distance_line_from_origin(line: Tuple[float, float]) -> float: +def distance_line_from_origin(line: tuple[float, float]) -> float: """Distance line from origin.""" return abs(line[1]) / math.sqrt(line[0] ** 2 + 1) -def get_bounds(l: float) -> List[Tuple[float, float]]: +def get_bounds(l: float) -> list[tuple[float, float]]: """Get bounds.""" result = [] @@ -70,7 +71,7 @@ def max_safe_chroma_for_l(l: float) -> float: return min(distance_line_from_origin(bound) for bound in get_bounds(l)) -def hpluv_to_lch(hpluv: Vector) -> Vector: +def hpluv_to_luv(hpluv: Vector) -> Vector: """Convert HPLuv to LCh.""" h, s, l = hpluv @@ -81,14 +82,16 @@ def hpluv_to_lch(hpluv: Vector) -> Vector: l = 0.0 else: _hx_max = max_safe_chroma_for_l(l) - c = _hx_max / 100 * s - return [l, c, util.constrain_hue(h)] + c = _hx_max * 0.01 * s + a, b = alg.polar_to_rect(c, h) + return [l, a, b] -def lch_to_hpluv(lch: Vector) -> Vector: +def luv_to_hpluv(luv: Vector) -> Vector: """Convert LCh to HPLuv.""" - l, c, h = lch + l = luv[0] + c, h = alg.rect_to_polar(luv[1], luv[2]) s = 0.0 if l > 100 - 1e-7: l = 100 @@ -103,11 +106,11 @@ def lch_to_hpluv(lch: Vector) -> Vector: class HPLuv(HSLish, Space): """HPLuv class.""" - BASE = 'lchuv' + BASE = 'luv' NAME = "hpluv" SERIALIZE = ("--hpluv",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("p", 0.0, 100.0, bound=True), Channel("l", 0.0, 100.0, bound=True) ) @@ -118,6 +121,14 @@ class HPLuv(HSLish, Space): } WHITE = WHITES['2deg']['D65'] + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + return self.from_base(self.to_base(coords)) + coords[0] %= 360.0 + return coords + def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" @@ -126,9 +137,14 @@ def is_achromatic(self, coords: Vector) -> bool: def to_base(self, coords: Vector) -> Vector: """To LChuv from HPLuv.""" - return hpluv_to_lch(coords) + return hpluv_to_luv(coords) def from_base(self, coords: Vector) -> Vector: """From LChuv to HPLuv.""" - return lch_to_hpluv(coords) + return luv_to_hpluv(coords) + + def radial_name(self) -> str: + """Radial name.""" + + return "p" diff --git a/lib/coloraide/spaces/hsi.py b/lib/coloraide/spaces/hsi.py index 12f95d24..af224e91 100644 --- a/lib/coloraide/spaces/hsi.py +++ b/lib/coloraide/spaces/hsi.py @@ -3,6 +3,7 @@ https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation """ +from __future__ import annotations import math from .hsv import HSV from ..cat import WHITES @@ -45,15 +46,7 @@ def hsi_to_srgb(hsi: Vector) -> Vector: x = c * z if math.isnan(h): # pragma: no cover - # In our current setup, this will not occur. If colors are naturally achromatic, - # they will resolve to zeros automatically even without this check. This case - # would be a shortcut normally. - # - # Unnatural cases, such as explicitly setting of hue to undefined, could cause this, - # but the conversion pipeline converts all undefined values to zero. We'd have to - # encounter a natural case due to conversion to or from HSI mid pipeline to trigger - # this, and we are not currently in a position where that would occur with sRGB being - # the only pass-through. + # NaN values are resolved before this point, so this will never execute. rgb = [0.0] * 3 elif 0 <= h <= 1: rgb = [c, x, 0] @@ -79,7 +72,7 @@ class HSI(HSV): NAME = "hsi" SERIALIZE = ("--hsi",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, 1.0, bound=True), Channel("i", 0.0, 1.0, bound=True) ) @@ -90,6 +83,7 @@ class HSI(HSV): } WHITE = WHITES['2deg']['D65'] GAMUT_CHECK = "srgb" + CLIP_SPACE = None def to_base(self, coords: Vector) -> Vector: """To sRGB from HSI.""" diff --git a/lib/coloraide/spaces/hsl/__init__.py b/lib/coloraide/spaces/hsl/__init__.py index b9fc60d1..0cb4f1dd 100644 --- a/lib/coloraide/spaces/hsl/__init__.py +++ b/lib/coloraide/spaces/hsl/__init__.py @@ -1,7 +1,8 @@ """HSL class.""" -from ...spaces import Space, HSLish +from __future__ import annotations +from ...spaces import HSLish, Space from ...cat import WHITES -from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT +from ...channels import Channel, FLG_ANGLE from ... import util from ...types import Vector @@ -27,6 +28,11 @@ def srgb_to_hsl(rgb: Vector) -> Vector: s = 0 if l == 0.0 or l == 1.0 else (mx - l) / min(l, 1 - l) h *= 60.0 + # Adjust for negative saturation + if s < 0: + s *= -1.0 + h += 180.0 + return [util.constrain_hue(h), s, l] @@ -56,9 +62,9 @@ class HSL(HSLish, Space): NAME = "hsl" SERIALIZE = ("--hsl",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), - Channel("s", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT), - Channel("l", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT) + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), + Channel("s", 0.0, 1.0, bound=True), + Channel("l", 0.0, 1.0, bound=True) ) CHANNEL_ALIASES = { "hue": "h", @@ -66,9 +72,19 @@ class HSL(HSLish, Space): "lightness": "l" } WHITE = WHITES['2deg']['D65'] - GAMUT_CHECK = "srgb" + GAMUT_CHECK = "srgb" # type: str | None + CLIP_SPACE = "hsl" # type: str | None + + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + coords[1] *= -1.0 + coords[0] += 180.0 + coords[0] %= 360.0 + return coords - def is_achromatic(self, coords: Vector) -> bool: + def is_achromatic(self, coords: Vector) -> bool | None: """Check if color is achromatic.""" return abs(coords[1]) < 1e-4 or coords[2] == 0.0 or abs(1 - coords[2]) < 1e-7 diff --git a/lib/coloraide/spaces/hsl/css.py b/lib/coloraide/spaces/hsl/css.py index 8b6d157e..6281d2d1 100644 --- a/lib/coloraide/spaces/hsl/css.py +++ b/lib/coloraide/spaces/hsl/css.py @@ -1,9 +1,10 @@ """HSL class.""" +from __future__ import annotations from .. import hsl as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color @@ -14,19 +15,30 @@ class HSL(base.HSL): def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = True, + percent: bool | Sequence[bool] | None = None, comma: bool = False, **kwargs: Any ) -> str: """Convert to CSS.""" + if percent is None: + if not color: + percent = True + else: + percent = False + elif isinstance(percent, bool): + if comma: + percent = True + elif comma: + percent = [False, True, True] + list(percent[3:4]) + return serialize.serialize_css( parent, func='hsl', @@ -36,7 +48,7 @@ def to_string( none=none, color=color, legacy=comma, - percent=True if comma else percent, + percent=percent, scale=100 ) @@ -45,7 +57,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/hsluv.py b/lib/coloraide/spaces/hsluv.py index e5fe3310..b8c259e5 100644 --- a/lib/coloraide/spaces/hsluv.py +++ b/lib/coloraide/spaces/hsluv.py @@ -24,24 +24,25 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from ..spaces import Space, HSLish from ..cat import WHITES from ..channels import Channel, FLG_ANGLE from .lab import EPSILON, KAPPA from .srgb_linear import XYZ_TO_RGB import math +from .. import algebra as alg from .. import util from ..types import Vector -from typing import List, Dict -def length_of_ray_until_intersect(theta: float, line: Dict[str, float]) -> float: +def length_of_ray_until_intersect(theta: float, line: dict[str, float]) -> float: """Length of ray until intersect.""" return line['intercept'] / (math.sin(theta) - line['slope'] * math.cos(theta)) -def get_bounds(l: float) -> List[Dict[str, float]]: +def get_bounds(l: float) -> list[dict[str, float]]: """Get bounds.""" result = [] @@ -72,7 +73,7 @@ def max_chroma_for_lh(l: float, h: float) -> float: return min(length for length in lengths if length >= 0) -def hsluv_to_lch(hsluv: Vector) -> Vector: +def hsluv_to_luv(hsluv: Vector) -> Vector: """Convert HSLuv to LCh.""" h, s, l = hsluv @@ -83,14 +84,17 @@ def hsluv_to_lch(hsluv: Vector) -> Vector: l = 0.0 else: _hx_max = max_chroma_for_lh(l, h) - c = _hx_max / 100.0 * s - return [l, c, util.constrain_hue(h)] + c = _hx_max * 0.01 * s + a, b = alg.polar_to_rect(c, h) + return [l, a, b] -def lch_to_hsluv(lch: Vector) -> Vector: + +def luv_to_hsluv(luv: Vector) -> Vector: """Convert LCh to HSLuv.""" - l, c, h = lch + l = luv[0] + c, h = alg.rect_to_polar(luv[1], luv[2]) s = 0.0 if l > 100 - 1e-7: l = 100.0 @@ -105,11 +109,11 @@ def lch_to_hsluv(lch: Vector) -> Vector: class HSLuv(HSLish, Space): """HSLuv class.""" - BASE = 'lchuv' + BASE = 'luv' NAME = "hsluv" SERIALIZE = ("--hsluv",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, 100.0, bound=True), Channel("l", 0.0, 100.0, bound=True) ) @@ -120,6 +124,15 @@ class HSLuv(HSLish, Space): } WHITE = WHITES['2deg']['D65'] GAMUT_CHECK = "srgb" + CLIP_SPACE = "hsluv" + + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + return self.from_base(self.to_base(coords)) + coords[0] %= 360.0 + return coords def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" @@ -129,9 +142,9 @@ def is_achromatic(self, coords: Vector) -> bool: def to_base(self, coords: Vector) -> Vector: """To LChuv from HSLuv.""" - return hsluv_to_lch(coords) + return hsluv_to_luv(coords) def from_base(self, coords: Vector) -> Vector: """From LChuv to HSLuv.""" - return lch_to_hsluv(coords) + return luv_to_hsluv(coords) diff --git a/lib/coloraide/spaces/hsv.py b/lib/coloraide/spaces/hsv.py index fc330b3f..440276db 100644 --- a/lib/coloraide/spaces/hsv.py +++ b/lib/coloraide/spaces/hsv.py @@ -1,12 +1,14 @@ """HSV class.""" +from __future__ import annotations from ..spaces import Space, HSVish +from .hsl import srgb_to_hsl, hsl_to_srgb from ..cat import WHITES from ..channels import Channel, FLG_ANGLE from .. import util from ..types import Vector -def hsv_to_hsl(hsv: Vector) -> Vector: +def hsv_to_srgb(hsv: Vector) -> Vector: """ HSV to HSL. @@ -17,18 +19,17 @@ def hsv_to_hsl(hsv: Vector) -> Vector: l = v * (1.0 - s / 2.0) s = 0.0 if l == 0.0 or l == 1.0 else (v - l) / min(l, 1.0 - l) - return [util.constrain_hue(h), s, l] + return hsl_to_srgb([h, s, l]) -def hsl_to_hsv(hsl: Vector) -> Vector: +def srgb_to_hsv(srgb: Vector) -> Vector: """ HSL to HSV. https://en.wikipedia.org/wiki/HSL_and_HSV#Interconversion """ - h, s, l = hsl - + h, s, l = srgb_to_hsl(srgb) v = l + s * min(l, 1.0 - l) s = 0.0 if v == 0.0 else 2 * (1.0 - l / v) @@ -38,11 +39,11 @@ def hsl_to_hsv(hsl: Vector) -> Vector: class HSV(HSVish, Space): """HSL class.""" - BASE = "hsl" + BASE = "srgb" NAME = "hsv" SERIALIZE = ("--hsv",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, 1.0, bound=True), Channel("v", 0.0, 1.0, bound=True) ) @@ -51,9 +52,18 @@ class HSV(HSVish, Space): "saturation": "s", "value": "v" } - GAMUT_CHECK = "srgb" + GAMUT_CHECK = "srgb" # type: str | None + CLIP_SPACE = "hsv" # type: str | None WHITE = WHITES['2deg']['D65'] + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + return self.from_base(self.to_base(coords)) + coords[0] %= 360.0 + return coords + def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" @@ -62,9 +72,9 @@ def is_achromatic(self, coords: Vector) -> bool: def to_base(self, coords: Vector) -> Vector: """To HSL from HSV.""" - return hsv_to_hsl(coords) + return hsv_to_srgb(coords) def from_base(self, coords: Vector) -> Vector: """From HSL to HSV.""" - return hsl_to_hsv(coords) + return srgb_to_hsv(coords) diff --git a/lib/coloraide/spaces/hunter_lab.py b/lib/coloraide/spaces/hunter_lab.py index e9a80cb4..4bae3f58 100644 --- a/lib/coloraide/spaces/hunter_lab.py +++ b/lib/coloraide/spaces/hunter_lab.py @@ -3,6 +3,7 @@ https://support.hunterlab.com/hc/en-us/articles/203997095-Hunter-Lab-Color-Scale-an08-96a2 """ +from __future__ import annotations from ..cat import WHITES from ..spaces.lab import Lab from .. import algebra as alg @@ -41,11 +42,11 @@ def hlab_to_xyz(hlab: Vector, white: VectorLike) -> Vector: ka = CKA * alg.nth_root(xn / CXN, 2) kb = CKB * alg.nth_root(zn / CZN, 2) l, a, b = hlab - l /= 100 + l *= 0.01 y = (l ** 2) * yn x = (((a * l) / ka) + (y / yn)) * xn z = (((b * l) / kb) - (y / yn)) * -zn - return alg.divide([x, y, z], 100, dims=alg.D1_SC) + return alg.multiply([x, y, z], 0.01, dims=alg.D1_SC) class HunterLab(Lab): diff --git a/lib/coloraide/spaces/hwb/__init__.py b/lib/coloraide/spaces/hwb/__init__.py index 59d9b9b6..978801dd 100644 --- a/lib/coloraide/spaces/hwb/__init__.py +++ b/lib/coloraide/spaces/hwb/__init__.py @@ -1,8 +1,9 @@ """HWB class.""" +from __future__ import annotations from ...spaces import Space, HWBish from ..hsl import srgb_to_hsl, hsl_to_srgb from ...cat import WHITES -from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT +from ...channels import Channel, FLG_ANGLE from ...types import Vector @@ -28,9 +29,9 @@ class HWB(HWBish, Space): NAME = "hwb" SERIALIZE = ("--hwb",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), - Channel("w", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT), - Channel("b", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT) + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), + Channel("w", 0.0, 1.0, bound=True), + Channel("b", 0.0, 1.0, bound=True) ) CHANNEL_ALIASES = { "hue": "h", diff --git a/lib/coloraide/spaces/hwb/css.py b/lib/coloraide/spaces/hwb/css.py index f6723164..eca92d9d 100644 --- a/lib/coloraide/spaces/hwb/css.py +++ b/lib/coloraide/spaces/hwb/css.py @@ -1,9 +1,10 @@ """HWB class.""" +from __future__ import annotations from .. import hwb as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color @@ -14,18 +15,21 @@ class HWB(base.HWB): def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = True, + percent: bool | Sequence[bool] | None = None, **kwargs: Any ) -> str: """Convert to CSS.""" + if percent is None: + percent = False if color else True + return serialize.serialize_css( parent, func='hwb', @@ -43,7 +47,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/ictcp.py b/lib/coloraide/spaces/ictcp.py index 7ab70baf..a1b1d649 100644 --- a/lib/coloraide/spaces/ictcp.py +++ b/lib/coloraide/spaces/ictcp.py @@ -3,13 +3,13 @@ https://professional.dolby.com/siteassets/pdfs/ictcp_dolbywhitepaper_v071.pdf """ +from __future__ import annotations from .lab import Lab from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT from .. import util from .. import algebra as alg from ..types import Vector -from typing import Tuple # All PQ Values are equivalent to defaults as stated in link below: # https://en.wikipedia.org/wiki/High-dynamic-range_video#Perceptual_quantizer @@ -26,15 +26,15 @@ # XYZ transform matrices xyz_to_lms_m = [ - [0.359132, 0.697604, -0.03578], - [-0.19218800000000003, 1.1003800000000001, 0.07554], - [0.006956, 0.074916, 0.8433400000000001] + [0.3592832590121218, 0.6976051147779497, -0.0358915932320289], + [-0.19208084637049927, 1.1004767970374318, 0.07537486585191187], + [0.0070797844607477164, 0.07483966621863658, 0.8433265453898765] ] lms_to_xyz_mi = [ - [2.070508203420414, -1.32670394499891, 0.20668057903526466], - [0.3650251372337387, 0.6804585253538308, -0.04546355870112316], - [-0.04950397021841151, -0.049503970218411505, 1.1880952852418765] + [2.0701522183894223, -1.3263473389671556, 0.20665104762940512], + [0.36473852097480713, 0.6805660249472276, -0.04530454592203474], + [-0.04974720753581203, -0.04926096669661379, 1.1880659249923042] ] # LMS to Izazbz matrices @@ -50,37 +50,39 @@ [1.0, 0.5600313357106791, -0.32062717498731885] ] +YW = 203 + def ictcp_to_xyz_d65(ictcp: Vector) -> Vector: """From ICtCp to XYZ.""" # Convert to LMS prime - pqlms = alg.dot(ictcp_to_lms_p_mi, ictcp, dims=alg.D2_D1) + pqlms = alg.matmul(ictcp_to_lms_p_mi, ictcp, dims=alg.D2_D1) # Decode PQ LMS to LMS lms = util.pq_st2084_eotf(pqlms) # Convert back to absolute XYZ D65 - absxyz = alg.dot(lms_to_xyz_mi, lms, dims=alg.D2_D1) + absxyz = alg.matmul(lms_to_xyz_mi, lms, dims=alg.D2_D1) # Convert back to normal XYZ D65 - return util.absxyz_to_xyz(absxyz) + return util.absxyz_to_xyz(absxyz, YW) def xyz_d65_to_ictcp(xyzd65: Vector) -> Vector: """From XYZ to ICtCp.""" - # Convert from XYZ D65 to an absolute XYZ D5 - absxyz = util.xyz_to_absxyz(xyzd65) + # Convert from XYZ D65 to an absolute XYZ D65 + absxyz = util.xyz_to_absxyz(xyzd65, YW) # Convert to LMS - lms = alg.dot(xyz_to_lms_m, absxyz, dims=alg.D2_D1) + lms = alg.matmul(xyz_to_lms_m, absxyz, dims=alg.D2_D1) # PQ encode the LMS pqlms = util.pq_st2084_oetf(lms) # Calculate Izazbz - return alg.dot(lms_p_to_ictcp_m, pqlms, dims=alg.D2_D1) + return alg.matmul(lms_p_to_ictcp_m, pqlms, dims=alg.D2_D1) class ICtCp(Lab): @@ -88,11 +90,11 @@ class ICtCp(Lab): BASE = "xyz-d65" NAME = "ictcp" - SERIALIZE = ("--ictcp",) + SERIALIZE = ("ictcp", "--ictcp",) CHANNELS = ( Channel("i", 0.0, 1.0), - Channel("ct", -0.5, 0.5, flags=FLG_MIRROR_PERCENT), - Channel("cp", -0.5, 0.5, flags=FLG_MIRROR_PERCENT) + Channel("ct", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), + Channel("cp", -1.0, 1.0, flags=FLG_MIRROR_PERCENT) ) CHANNEL_ALIASES = { "intensity": "i", diff --git a/lib/coloraide/spaces/igpgtg.py b/lib/coloraide/spaces/igpgtg.py index de53d8de..06268b64 100644 --- a/lib/coloraide/spaces/igpgtg.py +++ b/lib/coloraide/spaces/igpgtg.py @@ -3,16 +3,12 @@ https://www.ingentaconnect.com/content/ist/jpi/2020/00000003/00000002/art00002# """ +from __future__ import annotations from .ipt import IPT from ..channels import Channel, FLG_MIRROR_PERCENT from ..cat import WHITES from .. import algebra as alg -from .achromatic import Achromatic as _Achromatic -from .srgb_linear import lin_srgb_to_xyz -from .srgb import lin_srgb from ..types import Vector -from typing import Tuple, Any -import math XYZ_TO_LMS = [ [2.968, 2.741, -0.649], @@ -38,50 +34,29 @@ [0.02265698651657832, -0.004701151874826367, -0.030048158824914562] ] -ACHROMATIC_RESPONSE = [ - [0.01710472400677632, 7.497407788263645e-05, 289.0071727628954], - [0.022996189520032607, 0.00010079777395973735, 289.00717276289754], - [0.027343043084773422, 0.00011985106810105086, 289.00717276288543], - [0.03091688192289772, 0.00013551605464416815, 289.0071727629022], - [0.9741484960046702, 0.004269924798539192, 289.00717276289504], - [5.049390603804086, 0.022132681254572965, 289.0071727628912] -] # type: List[Vector] - def xyz_to_igpgtg(xyz: Vector) -> Vector: """XYZ to IgPgTg.""" - lms_in = alg.dot(XYZ_TO_LMS, xyz, dims=alg.D2_D1) + lms_in = alg.matmul(XYZ_TO_LMS, xyz, dims=alg.D2_D1) lms = [ - alg.npow(lms_in[0] / 18.36, 0.427), - alg.npow(lms_in[1] / 21.46, 0.427), - alg.npow(lms_in[2] / 19435, 0.427) + alg.spow(lms_in[0] / 18.36, 0.427), + alg.spow(lms_in[1] / 21.46, 0.427), + alg.spow(lms_in[2] / 19435, 0.427) ] - return alg.dot(LMS_TO_IGPGTG, lms, dims=alg.D2_D1) + return alg.matmul(LMS_TO_IGPGTG, lms, dims=alg.D2_D1) def igpgtg_to_xyz(itp: Vector) -> Vector: """IgPgTg to XYZ.""" - lms = alg.dot(IGPGTG_TO_LMS, itp, dims=alg.D2_D1) + lms = alg.matmul(IGPGTG_TO_LMS, itp, dims=alg.D2_D1) lms_in = [ alg.nth_root(lms[0], 0.427) * 18.36, alg.nth_root(lms[1], 0.427) * 21.46, alg.nth_root(lms[2], 0.427) * 19435 ] - return alg.dot(LMS_TO_XYZ, lms_in, dims=alg.D2_D1) - - -class Achromatic(_Achromatic): - """Test if color is achromatic.""" - - def convert(self, coords: Vector, **kwargs: Any) -> Vector: - """Convert to the target color space.""" - - lab = xyz_to_igpgtg(lin_srgb_to_xyz(lin_srgb(coords))) - l = lab[0] - c, h = alg.rect_to_polar(*lab[1:]) - return [l, c, h] + return alg.matmul(LMS_TO_XYZ, lms_in, dims=alg.D2_D1) class IgPgTg(IPT): @@ -89,7 +64,7 @@ class IgPgTg(IPT): BASE = "xyz-d65" NAME = "igpgtg" - SERIALIZE = ("--igpgtg",) # type: Tuple[str, ...] + SERIALIZE = ("--igpgtg",) # type: tuple[str, ...] CHANNELS = ( Channel("ig", 0.0, 1.0), Channel("pg", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), @@ -101,32 +76,6 @@ class IgPgTg(IPT): "tritan": "tg" } WHITE = WHITES['2deg']['D65'] - # Precalculated from: - # [ - # (1, 5, 1, 1000.0), - # (100, 101, 1, 100), - # (520, 521, 1, 100) - # ] - ACHROMATIC = Achromatic( - ACHROMATIC_RESPONSE, - 1e-5, - 1e-5, - 0.03126, - 'linear', - mirror=True - ) - - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index in (1, 2): - if not math.isnan(coords[index]): - return coords[index] - - return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1] - - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/ipt.py b/lib/coloraide/spaces/ipt.py index 3262f4c5..cd210b70 100644 --- a/lib/coloraide/spaces/ipt.py +++ b/lib/coloraide/spaces/ipt.py @@ -4,15 +4,11 @@ https://www.researchgate.net/publication/\ 221677980_Development_and_Testing_of_a_Color_Space_IPT_with_Improved_Hue_Uniformity. """ -from ..spaces import Space, Labish +from __future__ import annotations +from .lab import Lab from ..channels import Channel, FLG_MIRROR_PERCENT from .. import algebra as alg -from .achromatic import Achromatic as _Achromatic -from .srgb_linear import lin_srgb_to_xyz -from .srgb import lin_srgb from ..types import Vector -from typing import Any, List -import math from .. import util XYZ_TO_LMS = [ @@ -39,48 +35,27 @@ [1.0, 0.03261510991706641, -0.6768871830691794] ] -ACHROMATIC_RESPONSE = [ - [0.017066845239980113, 1.3218447776798768e-06, 329.76026731824635], - [0.022993026958471587, 1.7808336678784566e-06, 329.76026731797435], - [0.02737255832988907, 2.1200328924793134e-06, 329.7602673179663], - [0.03097697795223092, 2.3991989122003048e-06, 329.7602673180789], - [0.9999910919149724, 7.745034210925492e-05, 329.7602673179579], - [5.243613106559707, 0.0004061232467760781, 329.76026731814255] -] # type: List[Vector] - def xyz_to_ipt(xyz: Vector) -> Vector: """XYZ to IPT.""" - lms_p = [alg.npow(c, 0.43) for c in alg.dot(XYZ_TO_LMS, xyz, dims=alg.D2_D1)] - return alg.dot(LMS_P_TO_IPT, lms_p, dims=alg.D2_D1) + lms_p = [alg.spow(c, 0.43) for c in alg.matmul(XYZ_TO_LMS, xyz, dims=alg.D2_D1)] + return alg.matmul(LMS_P_TO_IPT, lms_p, dims=alg.D2_D1) def ipt_to_xyz(ipt: Vector) -> Vector: """IPT to XYZ.""" - lms = [alg.nth_root(c, 0.43) for c in alg.dot(IPT_TO_LMS_P, ipt, dims=alg.D2_D1)] - return alg.dot(LMS_TO_XYZ, lms, dims=alg.D2_D1) - - -class Achromatic(_Achromatic): - """Test achromatic response.""" - - def convert(self, coords: Vector, **kwargs: Any) -> Vector: - """Convert to the target color space.""" - - lab = xyz_to_ipt(lin_srgb_to_xyz(lin_srgb(coords))) - l = lab[0] - c, h = alg.rect_to_polar(*lab[1:]) - return [l, c, h] + lms = [alg.nth_root(c, 0.43) for c in alg.matmul(IPT_TO_LMS_P, ipt, dims=alg.D2_D1)] + return alg.matmul(LMS_TO_XYZ, lms, dims=alg.D2_D1) -class IPT(Labish, Space): +class IPT(Lab): """The IPT class.""" BASE = "xyz-d65" NAME = "ipt" - SERIALIZE = ("--ipt",) # type: Tuple[str, ...] + SERIALIZE = ("--ipt",) # type: tuple[str, ...] CHANNELS = ( Channel("i", 0.0, 1.0), Channel("p", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), @@ -96,38 +71,6 @@ class IPT(Labish, Space): # We use chromaticity points (0.31270, 0.3290) which gives us an XYZ of ~[0.9505, 1.0000, 1.0890] # IPT uses XYZ of [0.9504, 1.0, 1.0889] which yields chromaticity points ~(0.3127035830618893, 0.32902313032606195) WHITE = tuple(util.xyz_to_xyY([0.9504, 1.0, 1.0889])[:-1]) # type: ignore[assignment] - # Precalculated from: - # [ - # (1, 5, 1, 1000.0), - # (100, 101, 1, 100), - # (520, 521, 1, 100) - # ] - ACHROMATIC = Achromatic( - ACHROMATIC_RESPONSE, - 1e-5, - 1e-5, - 0.00049, - 'linear', - mirror=True - ) # type: _Achromatic - - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index in (1, 2): - if not math.isnan(coords[index]): - return coords[index] - - return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1] - - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value - - def is_achromatic(self, coords: Vector) -> bool: - """Check if color is achromatic.""" - - m, h = alg.rect_to_polar(coords[1], coords[2]) - return self.ACHROMATIC.test(coords[0], m, h) def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/jzazbz.py b/lib/coloraide/spaces/jzazbz.py index a985a8d8..95548a2a 100644 --- a/lib/coloraide/spaces/jzazbz.py +++ b/lib/coloraide/spaces/jzazbz.py @@ -3,38 +3,26 @@ https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272 -There seems to be some debate on how to scale Jzazbz. Colour Science chooses not to scale at all. -Colorio seems to scale at 100. - -The spec mentions multiple times targeting a luminance of 10,000 cd/m^2. -Relative XYZ has Y=1 for media white -BT.2048 says media white Y=203 at PQ 58 +Relative XYZ has Y=100 for media white +BT.2048 says media white Y=203 at PQ 58, which is about 1000 cd/m^2. This is confirmed here: https://www.itu.int/dms_pub/itu-r/opb/rep/R-REP-BT.2408-3-2019-PDF-E.pdf -It is tough to tell who is correct as everything passes through the MATLAB scripts fine as it -just scales the results differently, so forward and backwards translation comes out great regardless, -but looking at the images in the spec, it seems the scaling using Y=203 at PQ 58 may be correct. It -is almost certain that some scaling is being applied and that applying none is almost certainly wrong. - If at some time that these assumptions are incorrect, we will be happy to alter the model. """ -from ..spaces import Space, Labish -from .achromatic import Achromatic as _Achromatic +from __future__ import annotations +from ..spaces import Space from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT from .. import util from .. import algebra as alg -from .lch import lab_to_lch -from .srgb_linear import lin_srgb_to_xyz -from .srgb import lin_srgb -from ..types import Vector -from typing import Any, List -import math +from ..types import Vector, Matrix # noqa: F401 +from .lab import Lab B = 1.15 G = 0.66 D = -0.56 D0 = 1.6295499532821566E-11 +YW = 203 # All PQ Values are equivalent to defaults as stated in link below except `M2` (and `IM2`): # https://en.wikipedia.org/wiki/High-dynamic-range_video#Perceptual_quantizer @@ -52,227 +40,107 @@ # XYZ transform matrices -xyz_to_lms_m = [ +XYZ_TO_LMS = [ [0.41478972, 0.579999, 0.014648], [-0.20151, 1.120649, 0.0531008], [-0.0166008, 0.2648, 0.6684799] ] -lms_to_xyz_mi = [ +LMS_TO_XYZ = [ [1.9242264357876069, -1.0047923125953657, 0.037651404030617994], [0.35031676209499907, 0.7264811939316552, -0.06538442294808501], [-0.09098281098284755, -0.31272829052307394, 1.5227665613052603] ] # LMS to Izazbz matrices -lms_p_to_izazbz_m = [ +LMS_P_TO_IZAZBZ = [ [0.5, 0.5, 0], [3.524, -4.066708, 0.542708], [0.199076, 1.096799, -1.295875] ] -izazbz_to_lms_p_mi = [ +IZAZBZ_TO_LMS_P = [ [1.0, 0.13860504327153927, 0.05804731615611883], [1.0, -0.1386050432715393, -0.058047316156118904], [1.0, -0.09601924202631895, -0.811891896056039] ] -ACHROMATIC_RESPONSE = [ - [0.0009185262133445958, 2.840443191466584e-06, 216.0885802021336], - [0.0032449260086909186, 8.875176418290735e-06, 216.0865538095895], - [0.007045724283550615, 1.742628198531671e-05, 216.08513073729978], - [0.009865500056099596, 2.317265885648471e-05, 216.08447255685712], - [0.012214842923826377, 2.768276363465363e-05, 216.0840428784794], - [0.014313828964294468, 3.15364404794859e-05, 216.08371809158197], - [0.016438077905865333, 3.5290049847676045e-05, 216.08343078937077], - [0.018605469297130712, 3.898508192957335e-05, 216.08317068540933], - [0.02080811172991238, 4.261478385537034e-05, 216.0829334034566], - [0.02303956968571666, 4.617490464976015e-05, 216.0827155404453], - [0.02529454916234039, 4.966297634911679e-05, 216.0825143871757], - [0.027568661881969103, 5.307781011104134e-05, 216.08232775391835], - [0.029858245862958505, 5.641913782115498e-05, 216.08215383175184], - [0.032160227098370596, 5.9687353969353895e-05, 216.0819911399305], - [0.03447201166859765, 6.288332788976944e-05, 216.08183842876093], - [0.03679140069552315, 6.600826589362147e-05, 216.08169464885202], - [0.03911652265347357, 6.906360946484213e-05, 216.0815588821907], - [0.04144577901905777, 7.20509595041043e-05, 216.08143037308045], - [0.04377780027866024, 7.497201997536904e-05, 216.0813084440367], - [0.046111410055380574, 7.78285557456021e-05, 216.08119250961198], - [0.04844559565682705, 8.062236105672717e-05, 216.08108206116256], - [0.050779483741710464, 8.335523609797642e-05, 216.08097664284261], - [0.053112320097915965, 8.602896956101964e-05, 216.0808758658446], - [0.05544345274603036, 8.864532585124454e-05, 216.08077937053], - [0.05777231775004377, 9.120603576758393e-05, 216.08068683882303], - [0.060098427245276025, 9.371278988327557e-05, 216.08059799668482], - [0.062421359292580254, 9.616723398809035e-05, 216.08051257202447], - [0.07282655496930118, 0.00010660656866708172, 216.08016488545363], - [0.09108704537148082, 0.00012302746725157782, 216.07966036150265], - [0.10900798276245852, 0.00013720664366179703, 216.0792568965177], - [0.12658714074774824, 0.00014958895848933898, 216.0789232434426], - [0.1438408486329413, 0.00016050730576011498, 216.07864044077417], - [0.16079190286441, 0.00017021501266814123, 216.07839616904852], - [0.17746453522326563, 0.0001789083001012923, 216.07818199543323], - [0.1938822908546569, 0.00018674170523656867, 216.07799190659145], - [0.21006717973109132, 0.00019383878540645526, 216.07782150011536], - [0.22603940395038172, 0.00020029967178728176, 216.0776674210859], - [0.24181734649813305, 0.0002062064957239789, 216.0775271074707], - [0.25741767436011453, 0.00021162735571841754, 216.0773985097046], - [0.27285548569428925, 0.00021661926443777217, 216.07728001861], - [0.28814446743495276, 0.00022123037188011426, 216.07717029782012], - [0.3032970476681226, 0.00022550166532677018, 216.07706827050953], - [0.318324536052124, 0.00022946828610769828, 216.07697303732877], - [0.3332372500029604, 0.00023316056105526304, 216.07688383235663], - [0.34804462653287077, 0.00023660481904069536, 216.0768000283236], - [0.3627553206321442, 0.00023982404450001245, 216.07672106548637], - [0.37737729148697624, 0.0002428384038912725, 216.07664646902424], - [0.3919178779258038, 0.0002456656744964799, 216.07657583096483], - [0.40638386443891256, 0.00024832159559521613, 216.07650879846213], - [0.420781539003173, 0.0002508201586917396, 216.07644505801895], - [0.4351167438075533, 0.0002531738482749366, 216.07638435201847], - [0.44939491983780117, 0.0002553938438855013, 216.0763264167306], - [0.46362114614988975, 0.00025749018901337296, 216.076271046612], - [0.47780017454650436, 0.000259471935031054, 216.0762180267072], - [0.49193646026919147, 0.0002613472624003357, 216.07616722683406], - [0.506034189231372, 0.0002631235853618668, 216.07611845640926], - [0.520097302241697, 0.0002648076406577341, 216.07607158130352], - [0.5341295166035784, 0.0002664055645672449, 216.07602649958457], - [0.5481343454215033, 0.0002679229595196272, 216.07598307638068], - [0.562115114898706, 0.00026936495156998765, 216.0759412038042], - [0.5760749798710889, 0.00027073624083088496, 216.0759007944157], - [0.5900169377887273, 0.0002720411453401863, 216.07586174613544], - [0.6039438413278134, 0.00027328363966826354, 216.07582400851473], - [0.6178584097918066, 0.0002744673887323733, 216.07578748393507], - [0.6317632394388409, 0.0002755957777534978, 216.07575212814703], - [0.6456608128558685, 0.00027667193897750657, 216.07571787420144], - [0.6595535074843613, 0.0002776987749361305, 216.0756845900282], - [0.6734436033881548, 0.00027867897881155425, 216.0756523336041], - [0.6873332903451649, 0.0002796150541276385, 216.07562100643182], - [0.7012246743322099, 0.0002805093307679912, 216.07559056780278], - [0.7151197834661946, 0.00028136397973813873, 216.07556096861626], - [0.7290205734555001, 0.00028218102717968054, 216.07553214064387], - [0.7429289326111091, 0.00028296236534187003, 216.07550410795596], - [0.7568466864597538, 0.0002837097645372413, 216.07547681413567], - [0.7707756019973733, 0.00028442488261536087, 216.0754501913429], - [0.7847173916174169, 0.0002851092732967129, 216.07542424437477], - [0.7986737167436694, 0.0002857643945778034, 216.07539893412002], - [0.8126461911946142, 0.0002863916163923197, 216.07537422001636], - [0.82663638430444, 0.0002869922264017996, 216.07535011510663], - [0.8406458238212122, 0.00028756743686351684, 216.07532657427606], - [0.8546759986026592, 0.0002881183896480139, 216.07530355785332], - [0.8687283611263583, 0.00028864616127277436, 216.07528106686505], - [0.8828043298308796, 0.0002891517676790176, 216.0752590661328], - [0.8969052913010638, 0.0002896361684484102, 216.07523755309595], - [0.9110326023119082, 0.0002901002704615418, 216.07521649953736], - [0.9251875917408455, 0.0002905449316470965, 216.07519588476185], - [0.939371562360337, 0.00029097096413252364, 216.0751757112678], - [0.9535857925200474, 0.0002913791373231644, 216.07515593963097], - [0.9678315377267531, 0.000291770180656392, 216.0751365593372] -] # type: List[Vector] - - -class Achromatic(_Achromatic): - """Test if color is achromatic.""" - - def convert(self, coords: Vector, **kwargs: Any) -> Vector: - """Convert to the target color space.""" - - return lab_to_lch(xyz_d65_to_jzazbz(lin_srgb_to_xyz(lin_srgb(coords)))) +def xyz_d65_to_izazbz(xyz: Vector, lms_matrix: Matrix, m2: float) -> Vector: + """Absolute XYZ to Izazbz.""" -def jzazbz_to_xyz_d65(jzazbz: Vector) -> Vector: - """From Jzazbz to XYZ.""" + xa, ya, za = xyz + xm = (B * xa) - ((B - 1) * za) + ym = (G * ya) - ((G - 1) * xa) - jz, az, bz = jzazbz + # Convert to LMS + lms = alg.matmul(XYZ_TO_LMS, [xm, ym, za], dims=alg.D2_D1) - # Calculate Iz - iz = (jz + D0) / (1 + D - D * (jz + D0)) + # PQ encode the LMS + pqlms = util.pq_st2084_oetf(lms, m2=m2) + + # Calculate Izazbz + return alg.matmul(lms_matrix, pqlms, dims=alg.D2_D1) + + +def izazbz_to_xyz_d65(izazbz: Vector, lms_matrix: Matrix, m2: float) -> Vector: + """Izazbz to absolute XYZ.""" # Convert to LMS prime - pqlms = alg.dot(izazbz_to_lms_p_mi, [iz, az, bz], dims=alg.D2_D1) + pqlms = alg.matmul(lms_matrix, izazbz, dims=alg.D2_D1) # Decode PQ LMS to LMS - lms = util.pq_st2084_eotf(pqlms, m2=M2) + lms = util.pq_st2084_eotf(pqlms, m2=m2) # Convert back to absolute XYZ D65 - xm, ym, za = alg.dot(lms_to_xyz_mi, lms, dims=alg.D2_D1) + xm, ym, za = alg.matmul(LMS_TO_XYZ, lms, dims=alg.D2_D1) xa = (xm + ((B - 1) * za)) / B ya = (ym + ((G - 1) * xa)) / G - # Convert back to normal XYZ D65 - return util.absxyz_to_xyz([xa, ya, za]) + return [xa, ya, za] -def xyz_d65_to_jzazbz(xyzd65: Vector) -> Vector: - """From XYZ to Jzazbz.""" +def jzazbz_to_xyz_d65(jzazbz: Vector) -> Vector: + """From Jzazbz to XYZ.""" - # Convert from XYZ D65 to an absolute XYZ D5 - xa, ya, za = util.xyz_to_absxyz(xyzd65) - xm = (B * xa) - ((B - 1) * za) - ym = (G * ya) - ((G - 1) * xa) + jz, az, bz = jzazbz - # Convert to LMS - lms = alg.dot(xyz_to_lms_m, [xm, ym, za], dims=alg.D2_D1) + # Calculate Iz + iz = alg.zdiv((jz + D0), (1 + D - D * (jz + D0))) - # PQ encode the LMS - pqlms = util.pq_st2084_oetf(lms, m2=M2) + # Convert back to normal XYZ D65 + return util.absxyz_to_xyz(izazbz_to_xyz_d65([iz, az, bz], IZAZBZ_TO_LMS_P, M2), YW) - # Calculate Izazbz - iz, az, bz = alg.dot(lms_p_to_izazbz_m, pqlms, dims=alg.D2_D1) + +def xyz_d65_to_jzazbz(xyzd65: Vector) -> Vector: + """From XYZ to Jzazbz.""" + + iz, az, bz = xyz_d65_to_izazbz(util.xyz_to_absxyz(xyzd65, YW), LMS_P_TO_IZAZBZ, M2) # Calculate Jz jz = ((1 + D) * iz) / (1 + (D * iz)) - D0 return [jz, az, bz] -class Jzazbz(Labish, Space): +class Jzazbz(Lab, Space): """Jzazbz class.""" BASE = "xyz-d65" NAME = "jzazbz" - SERIALIZE = ("--jzazbz",) + SERIALIZE = ("jzazbz", "--jzazbz",) CHANNELS = ( - Channel("jz", 0.0, 1.0, limit=(0.0, None)), - Channel("az", -0.5, 0.5, flags=FLG_MIRROR_PERCENT), - Channel("bz", -0.5, 0.5, flags=FLG_MIRROR_PERCENT) + Channel("jz", 0.0, 1.0), + Channel("az", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), + Channel("bz", -1.0, 1.0, flags=FLG_MIRROR_PERCENT) ) CHANNEL_ALIASES = { "lightness": 'jz', "a": 'az', - "b": 'bz' + "b": 'bz', + "j": 'jz' } WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' - # Precalculated from - # [ - # (1, 5, 5, 1000.0), - # (1, 52, 2, 200.0), - # (30, 521, 8, 100.0) - # ] - ACHROMATIC = Achromatic( - ACHROMATIC_RESPONSE, - 2.121e-7, - 9.863e-7, - 0.00039, - 'catrom', - ) - - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index in (1, 2): - if not math.isnan(coords[index]): - return coords[index] - - return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1] - - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value - - def is_achromatic(self, coords: Vector) -> bool: - """Check if color is achromatic.""" - - c, h = alg.rect_to_polar(coords[1], coords[2]) - return coords[0] == 0.0 or self.ACHROMATIC.test(coords[0], c, h) def to_base(self, coords: Vector) -> Vector: """To XYZ from Jzazbz.""" diff --git a/lib/coloraide/spaces/jzczhz.py b/lib/coloraide/spaces/jzczhz.py index 06a49d25..7ca71ead 100644 --- a/lib/coloraide/spaces/jzczhz.py +++ b/lib/coloraide/spaces/jzczhz.py @@ -3,12 +3,10 @@ https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272 """ -import math +from __future__ import annotations from ..cat import WHITES from .lch import LCh -from .jzazbz import Jzazbz from ..channels import Channel, FLG_ANGLE -from ..types import Vector class JzCzhz(LCh): @@ -20,41 +18,29 @@ class JzCzhz(LCh): BASE = "jzazbz" NAME = "jzczhz" - SERIALIZE = ("--jzczhz",) + SERIALIZE = ("jzczhz", "--jzczhz",) WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' CHANNEL_ALIASES = { "lightness": "jz", "chroma": "cz", - "hue": "hz" + "hue": "hz", + "h": 'hz', + 'c': 'cz', + 'j': 'jz' } - ACHROMATIC = Jzazbz.ACHROMATIC CHANNELS = ( - Channel("jz", 0.0, 1.0, limit=(0.0, None)), - Channel("cz", 0.0, 0.5, limit=(0.0, None)), - Channel("hz", 0.0, 360.0, flags=FLG_ANGLE, nans=ACHROMATIC.hue) + Channel("jz", 0.0, 1.0), + Channel("cz", 0.0, 1.0), + Channel("hz", 0.0, 360.0, flags=FLG_ANGLE) ) - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index == 2: - h = coords[2] - return self.ACHROMATIC.get_ideal_hue(coords[0]) if math.isnan(h) else h - - elif index == 1: - c = coords[1] - return self.ACHROMATIC.get_ideal_chroma(coords[0]) if math.isnan(c) else c - - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value - - def is_achromatic(self, coords: Vector) -> bool: - """Check if color is achromatic.""" - - return coords[0] == 0.0 or self.ACHROMATIC.test(*coords) - def hue_name(self) -> str: """Hue name.""" return "hz" + + def radial_name(self) -> str: + """Radial name.""" + + return "cz" diff --git a/lib/coloraide/spaces/lab/__init__.py b/lib/coloraide/spaces/lab/__init__.py index ce4e7d3b..fa9e753e 100644 --- a/lib/coloraide/spaces/lab/__init__.py +++ b/lib/coloraide/spaces/lab/__init__.py @@ -1,7 +1,13 @@ -"""Lab class.""" +""" +Lab class. + +https://ia802802.us.archive.org/23/items/gov.law.cie.15.2004/cie.15.2004.pdf +http://www.brucelindbloom.com/Eqn_Lab_to_XYZ.html +""" +from __future__ import annotations from ...spaces import Space, Labish from ...cat import WHITES -from ...channels import Channel, FLG_OPT_PERCENT, FLG_MIRROR_PERCENT +from ...channels import Channel, FLG_MIRROR_PERCENT from ... import util from ... import algebra as alg from ...types import VectorLike, Vector @@ -14,14 +20,7 @@ def lab_to_xyz(lab: Vector, white: VectorLike) -> Vector: - """ - Convert Lab to D50-adapted XYZ. - - http://www.brucelindbloom.com/Eqn_Lab_to_XYZ.html - - While the derivation is different than the specification, the results are the same as Appendix D: - https://www.cdvplus.cz/file/3-publikace-cie15-2004/ - """ + """Convert CIE Lab to XYZ using the reference white.""" l, a, b = lab @@ -42,14 +41,7 @@ def lab_to_xyz(lab: Vector, white: VectorLike) -> Vector: def xyz_to_lab(xyz: Vector, white: VectorLike) -> Vector: - """ - Assuming XYZ is relative to D50, convert to CIELab from CIE standard. - - http://www.brucelindbloom.com/Eqn_XYZ_to_Lab.html - - While the derivation is different than the specification, the results are the same: - https://www.cdvplus.cz/file/3-publikace-cie15-2004/ - """ + """Convert XYZ to CIE Lab using the reference white.""" # compute `xyz`, which is XYZ scaled relative to reference white xyz = alg.divide(xyz, white, dims=alg.D1) @@ -66,18 +58,14 @@ def xyz_to_lab(xyz: Vector, white: VectorLike) -> Vector: class Lab(Labish, Space): """Lab class.""" - BASE = "xyz-d50" - NAME = "lab" - SERIALIZE = ("--lab",) CHANNELS = ( - Channel("l", 0.0, 100.0, flags=FLG_OPT_PERCENT), - Channel("a", -125.0, 125.0, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT), - Channel("b", -125.0, 125.0, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT) + Channel("l", 0.0, 1.0), + Channel("a", 1.0, 1.0, flags=FLG_MIRROR_PERCENT), + Channel("b", 1.0, 1.0, flags=FLG_MIRROR_PERCENT) ) CHANNEL_ALIASES = { "lightness": "l" } - WHITE = WHITES['2deg']['D50'] def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" @@ -93,3 +81,17 @@ def from_base(self, coords: Vector) -> Vector: """From XYZ D50 to Lab.""" return xyz_to_lab(coords, util.xy_to_xyz(self.white())) + + +class CIELab(Lab): + """CIE Lab D50.""" + + BASE = "xyz-d50" + NAME = "lab" + SERIALIZE = ("--lab",) + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("a", -125.0, 125.0, flags=FLG_MIRROR_PERCENT), + Channel("b", -125.0, 125.0, flags=FLG_MIRROR_PERCENT) + ) + WHITE = WHITES['2deg']['D50'] diff --git a/lib/coloraide/spaces/lab/css.py b/lib/coloraide/spaces/lab/css.py index 9fea0204..c36475af 100644 --- a/lib/coloraide/spaces/lab/css.py +++ b/lib/coloraide/spaces/lab/css.py @@ -1,27 +1,28 @@ """Lab class.""" +from __future__ import annotations from .. import lab as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color -class Lab(base.Lab): +class Lab(base.CIELab): """Lab class.""" def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS.""" @@ -42,7 +43,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/lab_d65.py b/lib/coloraide/spaces/lab_d65.py index a66b2da6..3b04cd37 100644 --- a/lib/coloraide/spaces/lab_d65.py +++ b/lib/coloraide/spaces/lab_d65.py @@ -1,10 +1,11 @@ """Lab D65 class.""" +from __future__ import annotations from ..cat import WHITES -from .lab import Lab +from .lab import CIELab from ..channels import Channel, FLG_MIRROR_PERCENT -class LabD65(Lab): +class LabD65(CIELab): """Lab D65 class.""" BASE = 'xyz-d65' diff --git a/lib/coloraide/spaces/lch/__init__.py b/lib/coloraide/spaces/lch/__init__.py index c52733da..68c46f1e 100644 --- a/lib/coloraide/spaces/lch/__init__.py +++ b/lib/coloraide/spaces/lch/__init__.py @@ -1,7 +1,8 @@ """LCh class.""" +from __future__ import annotations from ...spaces import Space, LChish from ...cat import WHITES -from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT +from ...channels import Channel, FLG_ANGLE from ... import util import math from ...types import Vector @@ -35,12 +36,9 @@ def lch_to_lab(lch: Vector) -> Vector: class LCh(LChish, Space): """LCh class.""" - BASE = "lab" - NAME = "lch" - SERIALIZE = ("--lch",) CHANNELS = ( - Channel("l", 0.0, 100.0, flags=FLG_OPT_PERCENT), - Channel("c", 0.0, 150.0, limit=(0.0, None), flags=FLG_OPT_PERCENT), + Channel("l", 0.0, 1.0), + Channel("c", 0.0, 1.0), Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) CHANNEL_ALIASES = { @@ -48,12 +46,21 @@ class LCh(LChish, Space): "chroma": "c", "hue": "h" } - WHITE = WHITES['2deg']['D50'] - def is_achromatic(self, coords: Vector) -> bool: + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + coords[1] *= -1.0 + coords[2] += 180.0 + coords[2] %= 360.0 + return coords + + def is_achromatic(self, coords: Vector) -> bool | None: """Check if color is achromatic.""" - return coords[1] < ACHROMATIC_THRESHOLD + # Account for both positive and negative chroma + return abs(coords[1]) < ACHROMATIC_THRESHOLD def to_base(self, coords: Vector) -> Vector: """To Lab from LCh.""" @@ -64,3 +71,22 @@ def from_base(self, coords: Vector) -> Vector: """From Lab to LCh.""" return lab_to_lch(coords) + + +class CIELCh(LCh): + """CIE LCh D50.""" + + BASE = "lab" + NAME = "lch" + SERIALIZE = ("--lch",) + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("c", 0.0, 150.0), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) + CHANNEL_ALIASES = { + "lightness": "l", + "chroma": "c", + "hue": "h" + } + WHITE = WHITES['2deg']['D50'] diff --git a/lib/coloraide/spaces/lch/css.py b/lib/coloraide/spaces/lch/css.py index bf274f4b..89b2b1ec 100644 --- a/lib/coloraide/spaces/lch/css.py +++ b/lib/coloraide/spaces/lch/css.py @@ -1,27 +1,28 @@ """LCh class.""" +from __future__ import annotations from .. import lch as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color -class LCh(base.LCh): +class LCh(base.CIELCh): """LCh class.""" def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS.""" @@ -42,7 +43,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/lch99o.py b/lib/coloraide/spaces/lch99o.py index 170457e6..d5e0ef24 100644 --- a/lib/coloraide/spaces/lch99o.py +++ b/lib/coloraide/spaces/lch99o.py @@ -1,4 +1,5 @@ """DIN99o LCh class.""" +from __future__ import annotations from ..cat import WHITES from .lch import LCh from ..channels import Channel, FLG_ANGLE @@ -13,6 +14,6 @@ class LCh99o(LCh): WHITE = WHITES['2deg']['D65'] CHANNELS = ( Channel("l", 0.0, 100.0), - Channel("c", 0.0, 60.0, limit=(0.0, None)), + Channel("c", 0.0, 60.0), Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) diff --git a/lib/coloraide/spaces/lch_d65.py b/lib/coloraide/spaces/lch_d65.py index 705e8478..dbc46bbd 100644 --- a/lib/coloraide/spaces/lch_d65.py +++ b/lib/coloraide/spaces/lch_d65.py @@ -1,10 +1,11 @@ """LCh D65 class.""" +from __future__ import annotations from ..cat import WHITES -from .lch import LCh +from .lch import CIELCh from ..channels import Channel, FLG_ANGLE -class LChD65(LCh): +class LChD65(CIELCh): """LCh D65 class.""" BASE = "lab-d65" @@ -13,6 +14,6 @@ class LChD65(LCh): WHITE = WHITES['2deg']['D65'] CHANNELS = ( Channel("l", 0.0, 100.0), - Channel("c", 0.0, 160.0, limit=(0.0, None)), + Channel("c", 0.0, 160.0), Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) diff --git a/lib/coloraide/spaces/lchuv.py b/lib/coloraide/spaces/lchuv.py index c0352624..2cf47378 100644 --- a/lib/coloraide/spaces/lchuv.py +++ b/lib/coloraide/spaces/lchuv.py @@ -1,4 +1,5 @@ """LChuv class.""" +from __future__ import annotations from ..spaces import Space from ..cat import WHITES from ..channels import Channel, FLG_ANGLE @@ -15,7 +16,7 @@ class LChuv(LCh, Space): WHITE = WHITES['2deg']['D65'] CHANNELS = ( Channel("l", 0.0, 100.0), - Channel("c", 0.0, 220.0, limit=(0.0, None)), + Channel("c", 0.0, 220.0), Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) diff --git a/lib/coloraide/spaces/luv.py b/lib/coloraide/spaces/luv.py index 18117398..b96404b6 100644 --- a/lib/coloraide/spaces/luv.py +++ b/lib/coloraide/spaces/luv.py @@ -3,6 +3,7 @@ https://en.wikipedia.org/wiki/CIELuv """ +from __future__ import annotations from ..spaces import Space, Labish from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT @@ -10,10 +11,9 @@ from .. import util from .. import algebra as alg from ..types import Vector -from typing import Tuple -def xyz_to_luv(xyz: Vector, white: Tuple[float, float]) -> Vector: +def xyz_to_luv(xyz: Vector, white: tuple[float, float]) -> Vector: """XYZ to Luv.""" u, v = util.xy_to_uv(util.xyz_to_xyY(xyz, white)[:2]) @@ -30,7 +30,7 @@ def xyz_to_luv(xyz: Vector, white: Tuple[float, float]) -> Vector: ] -def luv_to_xyz(luv: Vector, white: Tuple[float, float]) -> Vector: +def luv_to_xyz(luv: Vector, white: tuple[float, float]) -> Vector: """Luv to XYZ.""" l, u, v = luv diff --git a/lib/coloraide/spaces/okhsl.py b/lib/coloraide/spaces/okhsl.py index 38970313..b2de5977 100644 --- a/lib/coloraide/spaces/okhsl.py +++ b/lib/coloraide/spaces/okhsl.py @@ -25,6 +25,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from .hsl import HSL from ..channels import Channel, FLG_ANGLE from .. import util @@ -33,7 +34,6 @@ from .. import algebra as alg from ..types import Vector, Matrix from . oklab import OKLAB_TO_LMS3 -from typing import Optional, List SRGBL_TO_LMS = [ [0.4122214694707629, 0.5363325372617349, 0.051445993267502196], @@ -67,9 +67,9 @@ # Limit [0.13110758, 1.81333971], # `Kn` coefficients - [1.35691251, -0.00926975, -1.15076744, -0.50647251, 0.00645585] + [1.35733652, -0.00915799, -1.1513021, -0.50559606, 0.00692167] ] -] # type: List[List[Vector]] +] # type: list[Matrix] FLT_MAX = sys.float_info.max @@ -142,9 +142,9 @@ def oklab_to_linear_rgb(lab: Vector, lms_to_rgb: Matrix) -> Vector: that transform the LMS values to the linear RGB space. """ - return alg.dot( + return alg.matmul( lms_to_rgb, - [c ** 3 for c in alg.dot(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], + [c ** 3 for c in alg.matmul(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -153,7 +153,7 @@ def find_cusp( a: float, b: float, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """ Finds L_cusp and C_cusp for a given hue. @@ -179,8 +179,8 @@ def find_gamut_intersection( c1: float, l0: float, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]], - cusp: Optional[Vector] = None + ok_coeff: list[Matrix], + cusp: Vector | None = None, ) -> float: """ Finds intersection of the line. @@ -273,7 +273,7 @@ def find_gamut_intersection( def get_cs( lab: Vector, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """Get Cs.""" @@ -309,7 +309,7 @@ def compute_max_saturation( a: float, b: float, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> float: """ Finds the maximum saturation possible for a given hue that fits in RGB. @@ -376,7 +376,7 @@ def compute_max_saturation( def okhsl_to_oklab( hsl: Vector, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """Convert Okhsl to Oklab.""" @@ -387,8 +387,8 @@ def okhsl_to_oklab( a = b = 0.0 if L != 0.0 and L != 1.0 and s != 0: - a_ = math.cos(alg.tau * h) - b_ = math.sin(alg.tau * h) + a_ = math.cos(math.tau * h) + b_ = math.sin(math.tau * h) c_0, c_mid, c_max = get_cs([L, a_, b_], lms_to_rgb, ok_coeff) @@ -425,7 +425,7 @@ def okhsl_to_oklab( def oklab_to_okhsl( lab: Vector, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """Oklab to Okhsl.""" @@ -434,7 +434,7 @@ def oklab_to_okhsl( l = toe(L) c = math.sqrt(lab[1] ** 2 + lab[2] ** 2) - h = 0.5 + math.atan2(-lab[2], -lab[1]) / alg.tau + h = 0.5 + math.atan2(-lab[2], -lab[1]) / math.tau if l != 0.0 and l != 1.0 and c != 0: a_ = lab[1] / c @@ -470,7 +470,7 @@ class Okhsl(HSL): NAME = "okhsl" SERIALIZE = ("--okhsl",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, 1.0, bound=True), Channel("l", 0.0, 1.0, bound=True) ) @@ -479,6 +479,16 @@ class Okhsl(HSL): "saturation": "s", "lightness": "l" } + GAMUT_CHECK = None + CLIP_SPACE = None + + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + return self.from_base(self.to_base(coords)) + coords[0] %= 360.0 + return coords def to_base(self, coords: Vector) -> Vector: """To Oklab from Okhsl.""" diff --git a/lib/coloraide/spaces/okhsv.py b/lib/coloraide/spaces/okhsv.py index bd59f8b0..f51a3c94 100644 --- a/lib/coloraide/spaces/okhsv.py +++ b/lib/coloraide/spaces/okhsv.py @@ -25,6 +25,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from .hsv import HSV from ..channels import FLG_ANGLE, Channel from .. import util @@ -32,13 +33,12 @@ import math from .. import algebra as alg from ..types import Vector, Matrix -from typing import List def okhsv_to_oklab( hsv: Vector, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """Convert from Okhsv to Oklab.""" @@ -51,8 +51,8 @@ def okhsv_to_oklab( # Avoid processing gray or colors with undefined hues if l != 0.0 and s != 0.0: - a_ = math.cos(alg.tau * h) - b_ = math.sin(alg.tau * h) + a_ = math.cos(math.tau * h) + b_ = math.sin(math.tau * h) cusp = find_cusp(a_, b_, lms_to_rgb, ok_coeff) s_max, t_max = to_st(cusp) @@ -92,7 +92,7 @@ def okhsv_to_oklab( def oklab_to_okhsv( lab: Vector, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """Oklab to Okhsv.""" @@ -101,7 +101,7 @@ def oklab_to_okhsv( v = toe(l) c = math.sqrt(lab[1] ** 2 + lab[2] ** 2) - h = 0.5 + math.atan2(-lab[2], -lab[1]) / alg.tau + h = 0.5 + math.atan2(-lab[2], -lab[1]) / math.tau if l != 0.0 and l != 1 and c != 0.0: a_ = lab[1] / c @@ -144,7 +144,7 @@ class Okhsv(HSV): NAME = "okhsv" SERIALIZE = ("--okhsv",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, 1.0, bound=True), Channel("v", 0.0, 1.0, bound=True) ) @@ -153,6 +153,8 @@ class Okhsv(HSV): "saturation": "s", "value": "v" } + GAMUT_CHECK = None + CLIP_SPACE = None def to_base(self, okhsv: Vector) -> Vector: """To Oklab from Okhsv.""" diff --git a/lib/coloraide/spaces/oklab/__init__.py b/lib/coloraide/spaces/oklab/__init__.py index 315166f5..296a43b1 100644 --- a/lib/coloraide/spaces/oklab/__init__.py +++ b/lib/coloraide/spaces/oklab/__init__.py @@ -25,8 +25,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from ...cat import WHITES -from ...channels import Channel, FLG_OPT_PERCENT, FLG_MIRROR_PERCENT +from ...channels import Channel, FLG_MIRROR_PERCENT from ... import algebra as alg from ...types import Vector from ..lab import Lab @@ -63,9 +64,9 @@ def oklab_to_xyz_d65(lab: Vector) -> Vector: """Convert from Oklab to XYZ D65.""" - return alg.dot( + return alg.matmul( LMS_TO_XYZD65, - [c ** 3 for c in alg.dot(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], + [c ** 3 for c in alg.matmul(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -73,9 +74,9 @@ def oklab_to_xyz_d65(lab: Vector) -> Vector: def xyz_d65_to_oklab(xyz: Vector) -> Vector: """XYZ D65 to Oklab.""" - return alg.dot( + return alg.matmul( LMS3_TO_OKLAB, - [alg.nth_root(c, 3) for c in alg.dot(XYZD65_TO_LMS, xyz, dims=alg.D2_D1)], + [alg.nth_root(c, 3) for c in alg.matmul(XYZD65_TO_LMS, xyz, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -87,9 +88,9 @@ class Oklab(Lab): NAME = "oklab" SERIALIZE = ("--oklab",) CHANNELS = ( - Channel("l", 0.0, 1.0, flags=FLG_OPT_PERCENT), - Channel("a", -0.4, 0.4, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT), - Channel("b", -0.4, 0.4, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT) + Channel("l", 0.0, 1.0), + Channel("a", -0.4, 0.4, flags=FLG_MIRROR_PERCENT), + Channel("b", -0.4, 0.4, flags=FLG_MIRROR_PERCENT) ) CHANNEL_ALIASES = { "lightness": "l" diff --git a/lib/coloraide/spaces/oklab/css.py b/lib/coloraide/spaces/oklab/css.py index 613927ca..321621c6 100644 --- a/lib/coloraide/spaces/oklab/css.py +++ b/lib/coloraide/spaces/oklab/css.py @@ -1,9 +1,10 @@ """Oklab class.""" +from __future__ import annotations from .. import oklab as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color @@ -14,14 +15,14 @@ class Oklab(base.Oklab): def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS.""" @@ -42,7 +43,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/oklch/__init__.py b/lib/coloraide/spaces/oklch/__init__.py index 0c0f4326..a0d9a0cc 100644 --- a/lib/coloraide/spaces/oklch/__init__.py +++ b/lib/coloraide/spaces/oklch/__init__.py @@ -23,9 +23,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from ..lch import LCh from ...cat import WHITES -from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT +from ...channels import Channel, FLG_ANGLE class OkLCh(LCh): @@ -35,8 +36,8 @@ class OkLCh(LCh): NAME = "oklch" SERIALIZE = ("--oklch",) CHANNELS = ( - Channel("l", 0.0, 1.0, flags=FLG_OPT_PERCENT), - Channel("c", 0.0, 0.4, limit=(0.0, None), flags=FLG_OPT_PERCENT), + Channel("l", 0.0, 1.0), + Channel("c", 0.0, 0.4), Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) CHANNEL_ALIASES = { diff --git a/lib/coloraide/spaces/oklch/css.py b/lib/coloraide/spaces/oklch/css.py index 2e66a922..e81057ca 100644 --- a/lib/coloraide/spaces/oklch/css.py +++ b/lib/coloraide/spaces/oklch/css.py @@ -1,9 +1,10 @@ """OkLCh class.""" +from __future__ import annotations from .. import oklch as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color @@ -14,14 +15,14 @@ class OkLCh(base.OkLCh): def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS.""" @@ -42,7 +43,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/orgb.py b/lib/coloraide/spaces/orgb.py index 986626b9..313f1a4b 100644 --- a/lib/coloraide/spaces/orgb.py +++ b/lib/coloraide/spaces/orgb.py @@ -3,13 +3,13 @@ https://graphics.stanford.edu/~boulos/papers/orgb_sig.pdf """ +from __future__ import annotations import math from .. import algebra as alg from ..spaces import Space, Labish from ..types import Vector from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT -from typing import Tuple RGB_TO_LC1C2 = [ [0.2990, 0.5870, 0.1140], @@ -26,13 +26,13 @@ def rotate(v: Vector, d: float) -> Vector: m = alg.identity(3) m[1][1:] = math.cos(d), -math.sin(d) m[2][1:] = math.sin(d), math.cos(d) - return alg.dot(m, v, dims=alg.D2_D1) + return alg.matmul(m, v, dims=alg.D2_D1) def srgb_to_orgb(rgb: Vector) -> Vector: """Convert sRGB to oRGB.""" - lcc = alg.dot(RGB_TO_LC1C2, rgb, dims=alg.D2_D1) + lcc = alg.matmul(RGB_TO_LC1C2, rgb, dims=alg.D2_D1) theta = math.atan2(lcc[2], lcc[1]) theta0 = theta atheta = abs(theta) @@ -55,7 +55,7 @@ def orgb_to_srgb(lcc: Vector) -> Vector: elif (math.pi / 2) <= atheta0 <= math.pi: theta = math.copysign((math.pi / 3) + (4 / 3) * (atheta0 - math.pi / 2), theta0) - return alg.dot(LC1C2_TO_RGB, rotate(lcc, theta - theta0), dims=alg.D2_D1) + return alg.matmul(LC1C2_TO_RGB, rotate(lcc, theta - theta0), dims=alg.D2_D1) class oRGB(Labish, Space): @@ -74,6 +74,7 @@ class oRGB(Labish, Space): CHANNEL_ALIASES = { "luma": "l" } + GAMUT_CHECK = 'srgb' def to_base(self, coords: Vector) -> Vector: """To base from oRGB.""" diff --git a/lib/coloraide/spaces/prismatic.py b/lib/coloraide/spaces/prismatic.py index 364b8294..4e85eda5 100644 --- a/lib/coloraide/spaces/prismatic.py +++ b/lib/coloraide/spaces/prismatic.py @@ -6,13 +6,13 @@ http://psgraphics.blogspot.com/2015/10/prismatic-color-model.html https://studylib.net/doc/14656976/the-prismatic-color-space-for-rgb-computations """ +from __future__ import annotations from ..spaces import Space from ..channels import Channel from ..cat import WHITES from ..types import Vector from .. import algebra as alg import math -from typing import Tuple def srgb_to_lrgb(rgb: Vector) -> Vector: @@ -37,7 +37,7 @@ class Prismatic(Space): BASE = "srgb" NAME = "prismatic" - SERIALIZE = ("--prismatic",) # type: Tuple[str, ...] + SERIALIZE = ("--prismatic",) # type: tuple[str, ...] EXTENDED_RANGE = False CHANNELS = ( Channel("l", 0.0, 1.0, bound=True), @@ -52,16 +52,18 @@ class Prismatic(Space): "blue": 'b' } WHITE = WHITES['2deg']['D65'] + GAMUT_CHECK = 'srgb' + CLIP_SPACE = 'prismatic' def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" - if alg.isclose(0.0, coords[0], abs_tol=1e-4, dims=alg.SC): + if math.isclose(0.0, coords[0], abs_tol=1e-4): return True white = [1, 1, 1] for x in alg.vcross(coords[:-1], white): - if not alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): + if not math.isclose(0.0, x, abs_tol=1e-5): return False return True diff --git a/lib/coloraide/spaces/prophoto_rgb.py b/lib/coloraide/spaces/prophoto_rgb.py index 01e7af04..13324092 100644 --- a/lib/coloraide/spaces/prophoto_rgb.py +++ b/lib/coloraide/spaces/prophoto_rgb.py @@ -1,6 +1,7 @@ """Pro Photo RGB color class.""" +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -23,7 +24,7 @@ def lin_prophoto(rgb: Vector) -> Vector: if abs(i) < ET2: result.append(i / 16.0) else: - result.append(alg.npow(i, 1.8)) + result.append(alg.spow(i, 1.8)) return result @@ -46,13 +47,18 @@ def gam_prophoto(rgb: Vector) -> Vector: return result -class ProPhotoRGB(sRGB): +class ProPhotoRGB(sRGBLinear): """Pro Photo RGB class.""" BASE = "prophoto-rgb-linear" NAME = "prophoto-rgb" WHITE = WHITES['2deg']['D50'] + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE + def to_base(self, coords: Vector) -> Vector: """To XYZ from Pro Photo RGB.""" diff --git a/lib/coloraide/spaces/prophoto_rgb_linear.py b/lib/coloraide/spaces/prophoto_rgb_linear.py index 00879983..520777e0 100644 --- a/lib/coloraide/spaces/prophoto_rgb_linear.py +++ b/lib/coloraide/spaces/prophoto_rgb_linear.py @@ -1,6 +1,7 @@ """Linear Pro Photo RGB color class.""" +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -25,16 +26,16 @@ def lin_prophoto_to_xyz(rgb: Vector) -> Vector: http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html """ - return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_prophoto(xyz: Vector) -> Vector: """Convert XYZ to linear-light prophoto-rgb.""" - return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) -class ProPhotoRGBLinear(sRGB): +class ProPhotoRGBLinear(sRGBLinear): """Linear Pro Photo RGB class.""" BASE = "xyz-d50" diff --git a/lib/coloraide/spaces/rec2020.py b/lib/coloraide/spaces/rec2020.py index 6e025a64..4e5cfb2a 100644 --- a/lib/coloraide/spaces/rec2020.py +++ b/lib/coloraide/spaces/rec2020.py @@ -1,6 +1,6 @@ """Rec 2020 color class.""" -from ..cat import WHITES -from .srgb import sRGB +from __future__ import annotations +from .srgb_linear import sRGBLinear import math from .. import algebra as alg from ..types import Vector @@ -47,12 +47,16 @@ def gam_2020(rgb: Vector) -> Vector: return result -class Rec2020(sRGB): +class Rec2020(sRGBLinear): """Rec 2020 class.""" BASE = "rec2020-linear" NAME = "rec2020" - WHITE = WHITES['2deg']['D65'] + + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE def to_base(self, coords: Vector) -> Vector: """To XYZ from Rec. 2020.""" diff --git a/lib/coloraide/spaces/rec2020_linear.py b/lib/coloraide/spaces/rec2020_linear.py index 8bc2cca6..950346e3 100644 --- a/lib/coloraide/spaces/rec2020_linear.py +++ b/lib/coloraide/spaces/rec2020_linear.py @@ -1,6 +1,7 @@ """Linear Rec 2020 color class.""" +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -29,16 +30,16 @@ def lin_2020_to_xyz(rgb: Vector) -> Vector: http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html """ - return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_2020(xyz: Vector) -> Vector: """Convert XYZ to linear-light rec-2020.""" - return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) -class Rec2020Linear(sRGB): +class Rec2020Linear(sRGBLinear): """Linear Rec 2020 class.""" BASE = "xyz-d65" diff --git a/lib/coloraide/spaces/rec2100_hlg.py b/lib/coloraide/spaces/rec2100_hlg.py index f14ef529..854b1e7d 100644 --- a/lib/coloraide/spaces/rec2100_hlg.py +++ b/lib/coloraide/spaces/rec2100_hlg.py @@ -14,8 +14,9 @@ Suggests the scale of 0.26496256042100724 to satisfy the above requirement. """ +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector import math @@ -29,6 +30,7 @@ class Environment: def __init__( self, + *, lw: float, lb: float, scale: float @@ -83,15 +85,24 @@ def hlg_eotf(values: Vector, env: Environment) -> Vector: return adjusted -class Rec2100HLG(sRGB): +class Rec2100HLG(sRGBLinear): """Rec. 2100 HLG class.""" - BASE = "rec2020-linear" + BASE = "rec2100-linear" NAME = "rec2100-hlg" - SERIALIZE = ('--rec2100-hlg',) + SERIALIZE = ('rec2100-hlg', '--rec2100-hlg',) WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' - ENV = Environment(1000, 0, SCALE) + ENV = Environment( + lw=1000, + lb=0, + scale=SCALE + ) + + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE def to_base(self, coords: Vector) -> Vector: """To base from Rec 2100 HLG.""" diff --git a/lib/coloraide/spaces/rec2100_linear.py b/lib/coloraide/spaces/rec2100_linear.py new file mode 100644 index 00000000..0813dd7b --- /dev/null +++ b/lib/coloraide/spaces/rec2100_linear.py @@ -0,0 +1,16 @@ +""" +Linear Rec. 2100. + +As defined in CSS, this is simply an alias for linear Rec. 2020. +""" +from __future__ import annotations +from ..cat import WHITES +from .rec2020_linear import Rec2020Linear + + +class Rec2100Linear(Rec2020Linear): + """Linear Rec. 2100 class.""" + + NAME = "rec2100-linear" + SERIALIZE = ('rec2100-linear',) + WHITE = WHITES['2deg']['D65'] diff --git a/lib/coloraide/spaces/rec2100_pq.py b/lib/coloraide/spaces/rec2100_pq.py index d69b3e8e..edef8d41 100644 --- a/lib/coloraide/spaces/rec2100_pq.py +++ b/lib/coloraide/spaces/rec2100_pq.py @@ -3,28 +3,35 @@ https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.2100-2-201807-I!!PDF-E.pdf """ +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB -from .. import algebra as alg +from .srgb_linear import sRGBLinear from ..types import Vector from .. import util +YW = 203 -class Rec2100PQ(sRGB): + +class Rec2100PQ(sRGBLinear): """Rec. 2100 PQ class.""" - BASE = "rec2020-linear" + BASE = "rec2100-linear" NAME = "rec2100-pq" - SERIALIZE = ('--rec2100-pq',) + SERIALIZE = ('rec2100-pq', '--rec2100-pq',) WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE + def to_base(self, coords: Vector) -> Vector: - """To XYZ from Rec. 2100 PQ.""" + """To base from Rec. 2100 PQ.""" - return alg.divide(util.pq_st2084_eotf(coords), util.YW, dims=alg.D1_SC) + return [c / YW for c in util.pq_st2084_eotf(coords)] def from_base(self, coords: Vector) -> Vector: - """From XYZ to Rec. 2100 PQ.""" + """From base to Rec. 2100 PQ.""" - return util.pq_st2084_oetf(alg.multiply(coords, util.YW, dims=alg.D1_SC)) + return util.pq_st2084_oetf([c * YW for c in coords]) diff --git a/lib/coloraide/spaces/rec709.py b/lib/coloraide/spaces/rec709.py index 4a309cee..1eef1417 100644 --- a/lib/coloraide/spaces/rec709.py +++ b/lib/coloraide/spaces/rec709.py @@ -7,7 +7,8 @@ - https://en.wikipedia.org/wiki/Rec._709 - https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf """ -from .srgb import sRGB +from __future__ import annotations +from .srgb_linear import sRGBLinear import math from .. import algebra as alg from ..types import Vector @@ -54,13 +55,18 @@ def gam_709(rgb: Vector) -> Vector: return result -class Rec709(sRGB): +class Rec709(sRGBLinear): """Rec. 709 class.""" BASE = "srgb-linear" NAME = "rec709" SERIALIZE = ("--rec709",) + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE + def to_base(self, coords: Vector) -> Vector: """To XYZ from Rec. 709.""" diff --git a/lib/coloraide/spaces/rlab.py b/lib/coloraide/spaces/rlab.py index 46b8b32e..aa57d518 100644 --- a/lib/coloraide/spaces/rlab.py +++ b/lib/coloraide/spaces/rlab.py @@ -4,6 +4,8 @@ https://scholarworks.rit.edu/cgi/viewcontent.cgi?article=1153&context=article Compared against http://markfairchild.org/files/AppModEx.xls """ +from __future__ import annotations +import math from ..cat import WHITES from .. import util from ..spaces.lab import Lab @@ -25,7 +27,7 @@ ] # Defaults -YN = 318.0 # `318 cd / m^2` +YN = 1000 / math.pi # `1000 lux == ~318.31 cd / m^2` # Sigma is usually defined as 1 / x, but we are using x due to the way we use them SURROUND = { @@ -49,20 +51,27 @@ class Environment: """RLAB environment.""" - def __init__(self, white: VectorLike, adapting_luminance: float, surround: float, discounting: float) -> None: + def __init__( + self, + *, + white: VectorLike, + adapting_luminance: float, + surround: str, + discounting: str + ) -> None: """Initialize.""" - self.xyz_w = util.xy_to_xyz(white) - self.surround = surround + self.ref_white = util.xy_to_xyz(white) + self.surround = SURROUND[surround] self.yn = adapting_luminance - self.d = discounting + self.d = alg.clamp(D[discounting] if isinstance(discounting, str) else discounting, 0.0, 1.0) self.ram = self.calc_ram() self.iram = alg.inv(self.ram) def calc_ram(self) -> Matrix: """Calculate RAM.""" - lms = alg.dot(M, self.xyz_w) + lms = alg.matmul(M, self.ref_white, dims=alg.D2_D1) a = [] # type: Vector s = sum(lms) for c in lms: @@ -77,16 +86,16 @@ def rlab_to_xyz(rlab: Vector, env: Environment) -> Vector: """RLAB to XYZ.""" LR, aR, bR = rlab - yr = LR / 100 - xr = alg.npow((aR / 430) + yr, env.surround) - zr = alg.npow(yr - (bR / 170), env.surround) - return alg.dot(env.iram, [xr, alg.npow(yr, env.surround), zr], dims=alg.D2_D1) + yr = LR * 0.01 + xr = alg.spow((aR / 430) + yr, env.surround) + zr = alg.spow(yr - (bR / 170), env.surround) + return alg.matmul(env.iram, [xr, alg.spow(yr, env.surround), zr], dims=alg.D2_D1) def xyz_to_rlab(xyz: Vector, env: Environment) -> Vector: """XYZ to RLAB.""" - xyz_ref = alg.dot(env.ram, xyz, dims=alg.D2_D1) + xyz_ref = alg.matmul(env.ram, xyz, dims=alg.D2_D1) xr, yr, zr = [alg.nth_root(c, env.surround) for c in xyz_ref] LR = 100 * yr aR = 430 * (xr - yr) @@ -108,7 +117,16 @@ class RLAB(Lab): ) # Using less than full discounting would require special achromatic handling # to identify achromatic colors as `a == b == 0.0` would no longer be true. - ENV = Environment(WHITE, YN, SURROUND['average'], D['hard-copy']) + ENV = Environment( + # D65 white point. + white=WHITE, + # 1000 lux or `~318.31 cd/m2` + adapting_luminance=YN, + # Average surround + surround='average', + # "Hard copy" or a degree of discount of 1, a.k.a full discounting of illuminant. + discounting='hard-copy' + ) def to_base(self, coords: Vector) -> Vector: """To XYZ from Hunter Lab.""" diff --git a/lib/coloraide/spaces/ryb.py b/lib/coloraide/spaces/ryb.py index 1dd3d46c..e8ced837 100644 --- a/lib/coloraide/spaces/ryb.py +++ b/lib/coloraide/spaces/ryb.py @@ -4,6 +4,7 @@ Gosset and Chen http://bahamas10.github.io/ryb/assets/ryb.pdf """ +from __future__ import annotations import math from ..spaces import Regular, Space from .. import algebra as alg @@ -76,7 +77,7 @@ def is_achromatic(self, coords: Vector) -> bool: coords = self.to_base(coords) for x in alg.vcross(coords, [1, 1, 1]): - if not alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): + if not math.isclose(0.0, x, abs_tol=1e-5): return False return True diff --git a/lib/coloraide/spaces/srgb/__init__.py b/lib/coloraide/spaces/srgb/__init__.py index 227dc1ef..6fa9020a 100644 --- a/lib/coloraide/spaces/srgb/__init__.py +++ b/lib/coloraide/spaces/srgb/__init__.py @@ -1,7 +1,6 @@ """sRGB color class.""" -from ...spaces import RGBish, Space -from ...cat import WHITES -from ...channels import Channel, FLG_OPT_PERCENT +from __future__ import annotations +from ..srgb_linear import sRGBLinear from ... import algebra as alg from ...types import Vector import math @@ -43,33 +42,18 @@ def gam_srgb(rgb: Vector) -> Vector: return result -class sRGB(RGBish, Space): +class sRGB(sRGBLinear): """sRGB class.""" BASE = "srgb-linear" NAME = "srgb" - CHANNELS = ( - Channel("r", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT), - Channel("g", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT), - Channel("b", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT) - ) - CHANNEL_ALIASES = { - "red": 'r', - "green": 'g', - "blue": 'b' - } - WHITE = WHITES['2deg']['D65'] - + SERIALIZE = ("srgb",) EXTENDED_RANGE = True - def is_achromatic(self, coords: Vector) -> bool: - """Test if color is achromatic.""" + def linear(self) -> str: + """Return linear version of the RGB (if available).""" - white = [1, 1, 1] - for x in alg.vcross(coords, white): - if not alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): - return False - return True + return self.BASE def from_base(self, coords: Vector) -> Vector: """From sRGB Linear to sRGB.""" diff --git a/lib/coloraide/spaces/srgb/css.py b/lib/coloraide/spaces/srgb/css.py index ee79f812..dc75e6e2 100644 --- a/lib/coloraide/spaces/srgb/css.py +++ b/lib/coloraide/spaces/srgb/css.py @@ -1,8 +1,9 @@ """sRGB color class.""" +from __future__ import annotations from .. import srgb as base from ...css import parse from ...css import serialize -from typing import Optional, Union, Any, Tuple, TYPE_CHECKING +from typing import Any, Tuple, TYPE_CHECKING, Sequence from ...types import Vector if TYPE_CHECKING: # pragma: no cover @@ -14,18 +15,18 @@ class sRGB(base.sRGB): def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[bool, str] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, hex: bool = False, # noqa: A002 names: bool = False, comma: bool = False, upper: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, compress: bool = False, **kwargs: Any ) -> str: @@ -53,7 +54,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> Tuple[Tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/srgb_linear.py b/lib/coloraide/spaces/srgb_linear.py index 7ebcc198..16148f7e 100644 --- a/lib/coloraide/spaces/srgb_linear.py +++ b/lib/coloraide/spaces/srgb_linear.py @@ -1,8 +1,11 @@ """sRGB Linear color class.""" +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB +from ..spaces import RGBish, Space +from ..channels import Channel from .. import algebra as alg from ..types import Vector +import math RGB_TO_XYZ = [ @@ -25,22 +28,40 @@ def lin_srgb_to_xyz(rgb: Vector) -> Vector: D65 (no chromatic adaptation) """ - return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_srgb(xyz: Vector) -> Vector: """Convert XYZ to linear-light sRGB.""" - return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) -class sRGBLinear(sRGB): +class sRGBLinear(RGBish, Space): """sRGB linear.""" BASE = 'xyz-d65' NAME = "srgb-linear" - SERIALIZE = ("srgb-linear",) WHITE = WHITES['2deg']['D65'] + CHANNELS = ( + Channel("r", 0.0, 1.0, bound=True), + Channel("g", 0.0, 1.0, bound=True), + Channel("b", 0.0, 1.0, bound=True) + ) + CHANNEL_ALIASES = { + "red": 'r', + "green": 'g', + "blue": 'b' + } + + def is_achromatic(self, coords: Vector) -> bool: + """Test if color is achromatic.""" + + white = [1, 1, 1] + for x in alg.vcross(coords, white): + if not math.isclose(0.0, x, abs_tol=1e-5): + return False + return True def to_base(self, coords: Vector) -> Vector: """To XYZ from sRGB Linear.""" diff --git a/lib/coloraide/spaces/ucs.py b/lib/coloraide/spaces/ucs.py index 921fab95..34e34168 100644 --- a/lib/coloraide/spaces/ucs.py +++ b/lib/coloraide/spaces/ucs.py @@ -3,6 +3,7 @@ http://en.wikipedia.org/wiki/CIE_1960_color_space#Relation_to_CIE_XYZ """ +from __future__ import annotations from ..spaces import Space from ..channels import Channel from ..cat import WHITES diff --git a/lib/coloraide/spaces/xyb.py b/lib/coloraide/spaces/xyb.py index e39a8c50..3b643100 100644 --- a/lib/coloraide/spaces/xyb.py +++ b/lib/coloraide/spaces/xyb.py @@ -3,12 +3,12 @@ https://ds.jpeg.org/whitepapers/jpeg-xl-whitepaper.pdf """ +from __future__ import annotations from .. import algebra as alg from ..spaces import Space, Labish from ..types import Vector from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT -from typing import Tuple BIAS = 0.00379307325527544933 BIAS_CBRT = alg.nth_root(BIAS, 3) @@ -48,9 +48,9 @@ def rgb_to_xyb(rgb: Vector) -> Vector: """Linear sRGB to XYB.""" - return alg.dot( + return alg.matmul( XYB_LMS_TO_XYB, - [alg.nth_root(c + BIAS, 3) - BIAS_CBRT for c in alg.dot(LRGB_TO_LMS, rgb, dims=alg.D2_D1)], + [alg.nth_root(c + BIAS, 3) - BIAS_CBRT for c in alg.matmul(LRGB_TO_LMS, rgb, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -62,9 +62,9 @@ def xyb_to_rgb(xyb: Vector) -> Vector: if not any(xyb): return [0.0] * 3 - return alg.dot( + return alg.matmul( LMS_TO_LRGB, - [(c + BIAS_CBRT) ** 3 - BIAS for c in alg.dot(XYB_TO_XYB_LMS, xyb, dims=alg.D2_D1)], + [(c + BIAS_CBRT) ** 3 - BIAS for c in alg.matmul(XYB_TO_XYB_LMS, xyb, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -82,7 +82,7 @@ class XYB(Labish, Space): Channel("b", -0.45, 0.45, flags=FLG_MIRROR_PERCENT) ) - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return Lab-ish names in the order L a b.""" channels = self.channels diff --git a/lib/coloraide/spaces/xyy.py b/lib/coloraide/spaces/xyy.py index e079ed76..66234e5b 100644 --- a/lib/coloraide/spaces/xyy.py +++ b/lib/coloraide/spaces/xyy.py @@ -3,6 +3,7 @@ https://en.wikipedia.org/wiki/CIE_1931_color_space#CIE_xy_chromaticity_diagram_and_the_CIE_xyY_color_space """ +from __future__ import annotations from ..spaces import Space from ..channels import Channel from ..cat import WHITES @@ -10,7 +11,6 @@ from ..types import Vector from .. import algebra as alg import math -from typing import Tuple class xyY(Space): @@ -29,12 +29,11 @@ class xyY(Space): def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" - if alg.isclose(0.0, coords[-1], abs_tol=1e-4, dims=alg.SC): + if math.isclose(0.0, coords[-1], abs_tol=1e-4): return True - for x in alg.vcross(coords[:-1], self.WHITE): - if not math.isclose(0.0, x, abs_tol=1e-6): - return False + if not math.isclose(0.0, alg.vcross(coords[:-1], self.WHITE), abs_tol=1e-6): + return False return True def to_base(self, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/xyz_d50.py b/lib/coloraide/spaces/xyz_d50.py index 7f3087bd..9d69c1ba 100644 --- a/lib/coloraide/spaces/xyz_d50.py +++ b/lib/coloraide/spaces/xyz_d50.py @@ -1,4 +1,5 @@ """XYZ class.""" +from __future__ import annotations from ..cat import WHITES from .xyz_d65 import XYZD65 diff --git a/lib/coloraide/spaces/xyz_d65.py b/lib/coloraide/spaces/xyz_d65.py index 8b9d3bb6..c12f42d8 100644 --- a/lib/coloraide/spaces/xyz_d65.py +++ b/lib/coloraide/spaces/xyz_d65.py @@ -1,4 +1,5 @@ """XYZ D65 class.""" +from __future__ import annotations import math from ..spaces import Space, RGBish from ..cat import WHITES @@ -6,7 +7,6 @@ from .. import util from .. import algebra as alg from ..types import Vector -from typing import Tuple class XYZD65(RGBish, Space): @@ -14,7 +14,7 @@ class XYZD65(RGBish, Space): BASE = "xyz-d65" NAME = "xyz-d65" - SERIALIZE = ("xyz-d65", 'xyz') # type: Tuple[str, ...] + SERIALIZE = ("xyz-d65", 'xyz') # type: tuple[str, ...] CHANNELS = ( Channel("x", 0.0, 1.0), Channel("y", 0.0, 1.0), @@ -26,7 +26,7 @@ def is_achromatic(self, coords: Vector) -> bool: """Is achromatic.""" for x in alg.vcross(coords, util.xy_to_xyz(self.white())): - if not alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): + if not math.isclose(0.0, x, abs_tol=1e-5): return False return True diff --git a/lib/coloraide/spaces/zcam_jmh.py b/lib/coloraide/spaces/zcam_jmh.py new file mode 100644 index 00000000..a793eeb5 --- /dev/null +++ b/lib/coloraide/spaces/zcam_jmh.py @@ -0,0 +1,449 @@ +""" +ZCAM. + +``` +- ZCAM: https://opg.optica.org/oe/fulltext.cfm?uri=oe-29-4-6036&id=447640. +- Supplemental ZCAM (inverse transform): https://opticapublishing.figshare.com/articles/journal_contribution/\ + Supplementary_document_for_ZCAM_a_psychophysical_model_for_colour_appearance_prediction_-_5022171_pdf/13640927. +- Two-stage chromatic adaptation by Qiyan Zhai and Ming R. Luo using CAM02: https://opg.optica.org/oe/\ + fulltext.cfm?uri=oe-26-6-7724&id=383537 +``` +""" +from __future__ import annotations +import math +import bisect +from .. import util +from .. import algebra as alg +from ..spaces import Space +from ..cat import WHITES +from ..channels import Channel, FLG_ANGLE +from ..types import Vector, VectorLike +from .lch import LCh, ACHROMATIC_THRESHOLD +from .jzazbz import izazbz_to_xyz_d65, xyz_d65_to_izazbz +from .. import cat + +DEF_ILLUMINANT_BI = util.xyz_to_absxyz(util.xy_to_xyz(cat.WHITES['2deg']['E']), yw=100.0) +CAT02 = cat.CAT02.MATRIX +CAT02_INV = alg.inv(CAT02) + +# ZCAM uses a slightly different matrix than Jzazbz +# It updates how `Iz` is calculated. +LMS_P_TO_IZAZBZ = [ + [0.0, 1.0, 0.0], + [3.524, -4.066708, 0.542708], + [0.199076, 1.096799, -1.295875] +] +IZAZBZ_TO_LMS_P = alg.inv(LMS_P_TO_IZAZBZ) + +SURROUND = { + 'dark': (0.8, 0.525, 0.8), + 'dim': (0.9, 0.59, 0.9), + 'average': (1, 0.69, 1) +} + +HUE_QUADRATURE = { + # Red, Yellow, Green, Blue, Red + "h": (33.44, 89.29, 146.30, 238.36, 393.44), + "e": (0.68, 0.64, 1.52, 0.77, 0.68), + "H": (0.0, 100.0, 200.0, 300.0, 400.0) +} + + +def hue_quadrature(h: float) -> float: + """ + Hue to hue quadrature. + + https://onlinelibrary.wiley.com/doi/pdf/10.1002/col.22324 + """ + + hp = util.constrain_hue(h) + if hp <= HUE_QUADRATURE['h'][0]: + hp += 360 + + i = bisect.bisect_left(HUE_QUADRATURE['h'], hp) - 1 + hi, hii = HUE_QUADRATURE['h'][i:i + 2] + ei, eii = HUE_QUADRATURE['e'][i:i + 2] + Hi = HUE_QUADRATURE['H'][i] + + t = (hp - hi) / ei + return Hi + (100 * t) / (t + (hii - hp) / eii) + + +def inv_hue_quadrature(Hz: float) -> float: + """Hue quadrature to hue.""" + + Hp = (Hz % 400 + 400) % 400 + i = math.floor(0.01 * Hp) + Hp = Hp % 100 + hi, hii = HUE_QUADRATURE['h'][i:i + 2] + ei, eii = HUE_QUADRATURE['e'][i:i + 2] + + return util.constrain_hue((Hp * (eii * hi - ei * hii) - 100 * hi * eii) / (Hp * (eii - ei) - 100 * eii)) + + +def adapt( + xyz_b: Vector, + xyz_wb: Vector, + xyz_wd: Vector, + db: float, + dd: float, + xyz_wo: Vector = DEF_ILLUMINANT_BI +) -> Vector: + """ + Use 2 step chromatic adaptation by Qiyan Zhai and Ming R. Luo using CAM02. + + https://opg.optica.org/oe/fulltext.cfm?uri=oe-26-6-7724&id=383537 + + `xyz_b`: the sample color + `xyz_wb`: input illuminant of the sample color + `xyz_wd`: output illuminant + `xyz_wo`: the baseline illuminant, by default we use equal energy. + """ + + yb = xyz_wb[1] / xyz_wo[1] + yd = xyz_wd[1] / xyz_wo[1] + + rgb_b = alg.dot(CAT02, xyz_b, dims=alg.D2_D1) + rgb_wb = alg.dot(CAT02, xyz_wb, dims=alg.D2_D1) + rgb_wd = alg.dot(CAT02, xyz_wd, dims=alg.D2_D1) + rgb_wo = alg.dot(CAT02, xyz_wo, dims=alg.D2_D1) + + d_rgb_wb = alg.add( + alg.multiply(db * yb, alg.divide(rgb_wo, rgb_wb, dims=alg.D1), dims=alg.SC_D1), + 1 - db, + dims=alg.D1_SC + ) + d_rgb_wd = alg.add( + alg.multiply(dd * yd, alg.divide(rgb_wo, rgb_wd, dims=alg.D1), dims=alg.SC_D1), + 1 - dd, + dims=alg.D1_SC + ) + d_rgb = alg.divide(d_rgb_wb, d_rgb_wd, dims=alg.D1) + rgb_d = alg.multiply(d_rgb, rgb_b, dims=alg.D1) + return alg.dot(CAT02_INV, rgb_d, dims=alg.D2_D1) + + +class Environment: + """ + Class to calculate and contain any required environmental data (viewing conditions included). + + While originally for CIECAM models, the following applies to ZCAM as well. + Usage Guidelines for CIECAM97s (Nathan Moroney) + https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf + + white: This is the (x, y) chromaticity points for the white point. ZCAM is designed to use D65. + Generally, D65 should always be used, but we allow the possibility of variants of D65. This should + be the same value as set in the color class `WHITE` value. + + ref_white: The reference white in XYZ scaled by 100. + + adapting_luminance: This is the the luminance of the adapting field. The units are in cd/m2. + The equation is `L = (E * R) / π`, where `E` is the illuminance in lux, `R` is the reflectance, + and `L` is the luminance. If we assume a perfectly reflecting diffuser, `R` is assumed as 1. + For the "gray world" assumption, we must also divide by 5 (or multiply by 0.2 - 20%). + This results in `La = E / π * 0.2`. You can also ignore this gray world assumption converting + lux directly to nits (cd/m2) `lux / π`. + + background_luminance: The background is the region immediately surrounding the stimulus and + for images is the neighboring portion of the image. Generally, this value is set to a value of 20. + This implicitly assumes a gray world assumption. + + surround: The surround is categorical and is defined based on the relationship between the relative + luminance of the surround and the luminance of the scene or image white. While there are 4 defined + surrounds, usually just `average`, `dim`, and `dark` are used. + + Dark | 0% | Viewing film projected in a dark room + Dim | 0% to 20% | Viewing television + Average | > 20% | Viewing surface colors + + discounting: Whether we are discounting the illuminance. Done when eye is assumed to be fully adapted. + """ + + def __init__( + self, + *, + white: VectorLike, + reference_white: VectorLike, + adapting_luminance: float, + background_luminance: float, + surround: str, + discounting: bool + ): + """ + Initialize environmental viewing conditions. + + Using the specified viewing conditions, and general environmental data, + initialize anything that we can ahead of time to speed up the process. + """ + + self.output_white = util.xyz_to_absxyz(util.xy_to_xyz(white), yw=100) + self.ref_white = list(reference_white) + self.surround = surround + self.discounting = discounting + xyz_w = self.ref_white + + # The average luminance of the environment in `cd/m^2cd/m` (a.k.a. nits) + self.la = adapting_luminance + # The relative luminance of the nearby background + self.yb = background_luminance + # Absolute luminance of the reference white. + yw = xyz_w[1] + self.fb = math.sqrt(self.yb / yw) + self.fl = 0.171 * alg.nth_root(self.la, 3) * (1 - math.exp((-48 / 9) * self.la)) + + # Surround: dark, dim, and average + f, self.c, _ = SURROUND[self.surround] + self.fs = self.c + self.epsilon = 3.7035226210190005e-11 + self.rho = 1.7 * 2523 / (2 ** 5) + self.b = 1.15 + self.g = 0.66 + + self.izw = xyz_d65_to_izazbz(xyz_w, LMS_P_TO_IZAZBZ, self.rho)[0] - self.epsilon + self.qzw = ( + 2700 * alg.spow(self.izw, (1.6 * self.fs) / (self.fb ** 0.12)) * + ((self.fs ** 2.2) * (self.fb ** 0.5) * (self.fl ** 0.2)) + ) + + # Degree of adaptation calculating if not discounting illuminant (assumed eye is fully adapted) + self.d = alg.clamp(f * (1 - 1 / 3.6 * math.exp((-self.la - 42) / 92)), 0, 1) if not self.discounting else 1 + + +def zcam_to_xyz_d65( + Jz: float | None = None, + Cz: float | None = None, + hz: float | None = None, + Qz: float | None = None, + Mz: float | None = None, + Sz: float | None = None, + Vz: float | None = None, + Kz: float | None = None, + Wz: float | None = None, + Hz: float | None = None, + env: Environment | None = None +) -> Vector: + """ + From ZCAM to XYZ. + + Reverse calculation can actually be obtained from a small subset of the ZCAM components + Really, only one suitable value is needed for each type of attribute: (lightness/brightness), + (chroma/colorfulness/saturation), (hue/hue quadrature). If more than one for a given + category is given, we will fail as we have no idea which is the right one to use. Also, + if none are given, we must fail as well as there is nothing to calculate with. + """ + + # These check ensure one, and only one attribute for a given category is provided. + if not ((Jz is not None) ^ (Qz is not None)): + raise ValueError("Conversion requires one and only one: 'Jz' or 'Qz'") + + if not ( + (Cz is not None) ^ (Mz is not None) ^ (Sz is not None) ^ (Vz is not None) ^ (Kz is not None) ^ (Wz is not None) + ): + raise ValueError("Conversion requires one and only one: 'Cz', 'Mz', 'Sz', 'Vz', 'Kz', or 'Wz'") + + # Hue is absolutely required + if not ((hz is not None) ^ (Hz is not None)): + raise ValueError("Conversion requires one and only one: 'hz' or 'Hz'") + + # We need viewing conditions + if env is None: + raise ValueError("No viewing conditions/environment provided") + + # Black + if Jz == 0.0 or Qz == 0.0: + return [0.0, 0.0, 0.0] + + # Break hue into Cartesian components + h_rad = 0.0 + if hz is None: + hz = inv_hue_quadrature(Hz) # type: ignore[arg-type] + h_rad = math.radians(hz % 360) + cos_h = math.cos(h_rad) + sin_h = math.sin(h_rad) + hp = hz + if hp <= HUE_QUADRATURE['h'][0]: + hp += 360 + ez = 1.015 + math.cos(math.radians(89.038 + hp)) + + # Calculate `iz` from one of the lightness derived coordinates. + if Qz is None: + Qz = (Jz * 0.01) * env.qzw # type: ignore[operator] + + if Jz is None: + Jz = 100 * (Qz / env.qzw) + + iz = alg.nth_root( + Qz / ((env.fs ** 2.2) * (env.fb ** 0.5) * (env.fl ** 0.2) * 2700), (1.6 * env.fs) / (env.fb ** 0.12) + ) + + # Calculate `Mz` from the various chroma like parameters. + if Sz is not None: + Cz = Qz * Sz ** 2 / (100 * env.qzw * env.fl ** 1.2) + elif Vz is not None: + Cz = math.sqrt((Vz ** 2 - (Jz - 58) ** 2) / 3.4) + elif Kz is not None: + Cz = math.sqrt((((Kz - 100) / - 0.8) ** 2 - (Jz ** 2)) / 8) + elif Wz is not None: + Cz = math.sqrt((Wz - 100) ** 2 - (100 - Jz) ** 2) + + if Cz is not None: + Mz = (Cz / 100) * env.qzw + + Czp = alg.spow( + (Mz * (env.izw ** (0.78)) * (env.fb ** 0.1)) / (100 * (ez ** 0.068) * (env.fl ** 0.2)), + 1.0 / 0.37 / 2 + ) + + # Convert back to XYZ + az, bz = cos_h * Czp, sin_h * Czp + iz += env.epsilon + xyz_abs = izazbz_to_xyz_d65([iz, az, bz], IZAZBZ_TO_LMS_P, env.rho) + + return util.absxyz_to_xyz(adapt(xyz_abs, env.output_white, env.ref_white, env.d, env.d)) + + +def xyz_d65_to_zcam(xyzd65: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector: + """From XYZ to ZCAM.""" + + # Steps 4 - 7 + iz, az, bz = xyz_d65_to_izazbz( + adapt(util.xyz_to_absxyz(xyzd65), env.ref_white, env.output_white, env.d, env.d), + LMS_P_TO_IZAZBZ, + env.rho + ) + + # Step 8 + iz -= env.epsilon + + # Step 9 + hz = util.constrain_hue(math.degrees(math.atan2(bz, az))) + + # Step 10 + Hz = hue_quadrature(hz) if calc_hue_quadrature else alg.NaN + + # Step 11 + hp = hz + if hp <= HUE_QUADRATURE['h'][0]: + hp += 360 + ez = 1.015 + math.cos(math.radians(89.038 + hp)) + + # Step 12 + Qz = ( + 2700 * alg.spow(iz, (1.6 * env.fs) / (env.fb ** 0.12)) * + ((env.fs ** 2.2) * (env.fb ** 0.5) * (env.fl ** 0.2)) + ) + + # Step 13 + Jz = 100 * (Qz / env.qzw) + + # Step 14 + Mz = ( + 100 * ((az ** 2 + bz ** 2) ** (0.37)) * + ((alg.spow(ez, 0.068) * (env.fl ** 0.2)) / ((env.fb ** 0.1) * alg.spow(env.izw, 0.78))) + ) + + # Step 15 + Cz = 100 * (Mz / env.qzw) + + # Step 16 + Sz = 100 * (env.fl ** 0.6) * math.sqrt(Mz / Qz) if Qz else 0.0 + + # Step 17 + Vz = math.sqrt((Jz - 58) ** 2 + 3.4 * (Cz ** 2)) + + # Step 18 + Kz = 100 - 0.8 * math.sqrt(Jz ** 2 + 8 * (Cz ** 2)) + + # Step 19 + Wz = 100 - math.sqrt((100 - Jz) ** 2 + Cz ** 2) + + return [Jz, Cz, hz, Qz, Mz, Sz, Vz, Kz, Wz, Hz] + + +def xyz_d65_to_zcam_jmh(xyzd65: Vector, env: Environment) -> Vector: + """XYZ to ZCAM JMh.""" + + zcam = xyz_d65_to_zcam(xyzd65, env) + Jz, Mz, hz = zcam[0], zcam[4], zcam[2] + return [Jz, Mz, hz] + + +def zcam_jmh_to_xyz_d65(jmh: Vector, env: Environment) -> Vector: + """ZCAM JMh to XYZ.""" + + Jz, Mz, hz = jmh + return zcam_to_xyz_d65(Jz=Jz, Mz=Mz, hz=hz, env=env) + + +class ZCAMJMh(LCh, Space): + """ZCAM class (JMh).""" + + BASE = "xyz-d65" + NAME = "zcam-jmh" + SERIALIZE = ("--zcam-jmh",) + CHANNEL_ALIASES = { + "lightness": "jz", + "colorfulness": 'mz', + "hue": 'hz', + 'j': 'jz', + 'm': "mz", + 'h': 'hz' + } + WHITE = WHITES['2deg']['D65'] + DYNAMIC_RANGE = 'hdr' + + # Assuming sRGB which has a lux of 64 + ENV = Environment( + # D65 white point. + white=WHITE, + # The reference white in XYZ scaled by 100 + reference_white=util.xyz_to_absxyz(util.xy_to_xyz(WHITE), 100), + # Assuming sRGB which has a lux of 64: `((E * R) / PI)` where `R = 1`. + # Divided by 5 (or multiplied by 20%) assuming gray world. + adapting_luminance=64 / math.pi * 0.2, + # 20% relative to an XYZ luminance of 100 (scaled by 100) for the gray world assumption. + background_luminance=20, + # Assume an average surround + surround='average', + # Do not discount illuminant. + discounting=False + ) + CHANNELS = ( + Channel("jz", 0.0, 100.0), + Channel("mz", 0, 60.0), + Channel("hz", 0.0, 360.0, flags=FLG_ANGLE) + ) + + def normalize(self, coords: Vector) -> Vector: + """Normalize.""" + + if coords[1] < 0.0: + return self.from_base(self.to_base(coords)) + coords[2] %= 360.0 + return coords + + def is_achromatic(self, coords: Vector) -> bool | None: + """Check if color is achromatic.""" + + # Account for both positive and negative chroma + return coords[0] == 0 or abs(coords[1]) < ACHROMATIC_THRESHOLD + + def hue_name(self) -> str: + """Hue name.""" + + return "hz" + + def radial_name(self) -> str: + """Radial name.""" + + return "mz" + + def to_base(self, coords: Vector) -> Vector: + """From ZCAM JMh to XYZ.""" + + return zcam_jmh_to_xyz_d65(coords, self.ENV) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ to ZCAM JMh.""" + + return xyz_d65_to_zcam_jmh(coords, self.ENV) diff --git a/lib/coloraide/temperature/__init__.py b/lib/coloraide/temperature/__init__.py index 3ad13dbe..379256a4 100644 --- a/lib/coloraide/temperature/__init__.py +++ b/lib/coloraide/temperature/__init__.py @@ -1,7 +1,8 @@ """Temperature plugin.""" +from __future__ import annotations from abc import ABCMeta, abstractmethod from ..types import Plugin, Vector -from typing import TYPE_CHECKING, Union, Optional, Any, Type +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -13,24 +14,24 @@ class CCT(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def to_cct(self, color: 'Color', **kwargs: Any) -> Vector: + def to_cct(self, color: Color, **kwargs: Any) -> Vector: """Calculate a color's CCT.""" @abstractmethod def from_cct( self, - color: Type['Color'], + color: type[Color], space: str, kelvin: float, duv: float, scale: bool, - scale_space: Optional[str], + scale_space: str | None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Calculate a color that satisfies the CCT.""" -def cct(name: Optional[str], color: Union[Type['Color'], 'Color']) -> CCT: +def cct(name: str | None, color: type[Color] | Color) -> CCT: """Get the appropriate contrast plugin.""" if name is None: diff --git a/lib/coloraide/temperature/ohno_2013.py b/lib/coloraide/temperature/ohno_2013.py index 7ed86ebf..7b26dac2 100644 --- a/lib/coloraide/temperature/ohno_2013.py +++ b/lib/coloraide/temperature/ohno_2013.py @@ -3,6 +3,7 @@ https://www.researchgate.net/publication/263373260_Practical_Use_and_Calculation_of_CCT_and_Duv """ +from __future__ import annotations import math from . import planck from .. import cat @@ -11,7 +12,7 @@ from .. import algebra as alg from ..temperature import CCT from ..types import Vector, VectorLike -from typing import TYPE_CHECKING, Any, List, Tuple, Dict, Optional, Type +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -30,7 +31,7 @@ class BlackBodyCurve: def __init__( self, - cmfs: Dict[int, Tuple[float, float, float]] = cmfs.CIE_1931_2DEG, + cmfs: dict[int, tuple[float, float, float]] = cmfs.CIE_1931_2DEG, white: VectorLike = cat.WHITES['2deg']['D65'], planck_step: int = 5, chromaticity: str = 'uv-1960' @@ -83,7 +84,7 @@ def __init__( points.append([u, v]) self.spline2 = alg.interpolate(points, method='catrom') - def scale(self, point: float, domain: List[float]) -> float: + def scale(self, point: float, domain: Vector) -> float: """Scale the temperature point to match the range 0 - 1.""" # Extrapolation @@ -148,7 +149,7 @@ class Ohno2013(CCT): def __init__( self, - cmfs: Dict[int, Tuple[float, float, float]] = cmfs.CIE_1931_2DEG, + cmfs: dict[int, tuple[float, float, float]] = cmfs.CIE_1931_2DEG, white: VectorLike = cat.WHITES['2deg']['D65'], planck_step: int = 5 ): @@ -159,7 +160,7 @@ def __init__( def to_cct( self, - color: 'Color', + color: Color, start: float = 1000, end: float = 100000, samples: int = 10, @@ -172,12 +173,12 @@ def to_cct( u, v = color.split_chromaticity(self.CHROMATICITY)[:-1] last = samples - 1 index = 0 - table = [] # type: List[Tuple[float, float, float, float]] + table = [] # type: list[tuple[float, float, float, float]] # Each iteration we narrow the range until we are close enough for _ in range(iterations): table.clear() - lowest = alg.inf + lowest = math.inf index = 0 # Generate the Planckian table while tracking lowest distance @@ -251,14 +252,14 @@ def to_cct( def from_cct( self, - color: Type['Color'], + color: type[Color], space: str, kelvin: float, duv: float, scale: bool, - scale_space: Optional[str], + scale_space: str | None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Calculate a color that satisfies the CCT using Planck's law.""" u0, v0 = self.blackbody(kelvin, exact=True) diff --git a/lib/coloraide/temperature/planck.py b/lib/coloraide/temperature/planck.py index 0f1f9b72..5b48bc8a 100644 --- a/lib/coloraide/temperature/planck.py +++ b/lib/coloraide/temperature/planck.py @@ -3,10 +3,10 @@ https://en.wikipedia.org/wiki/Planckian_locus#The_Planckian_locus_in_the_XYZ_color_space """ +from __future__ import annotations import math from ..types import VectorLike, Vector from .. import util -from typing import Dict, Tuple # Constants for Planck's Law # Precise calculation @@ -24,7 +24,7 @@ def temp_to_xy_planckian_locus( temp: float, - cmfs: Dict[int, Tuple[float, float, float]], + cmfs: dict[int, tuple[float, float, float]], white: VectorLike, start: int = 360, end: int = 830, diff --git a/lib/coloraide/temperature/robertson_1968.py b/lib/coloraide/temperature/robertson_1968.py index 417c3b9a..d67204b1 100644 --- a/lib/coloraide/temperature/robertson_1968.py +++ b/lib/coloraide/temperature/robertson_1968.py @@ -6,6 +6,7 @@ - https://en.wikipedia.org/wiki/Correlated_color_temperature#Robertson's_method - http://www.brucelindbloom.com/index.html?Math.html """ +from __future__ import annotations import math from . import planck from .. import algebra as alg @@ -14,7 +15,7 @@ from .. import cmfs from ..temperature import CCT from ..types import Vector, VectorLike -from typing import TYPE_CHECKING, Any, Tuple, Dict, List, Optional, Type +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -33,7 +34,7 @@ class Robertson1968(CCT): def __init__( self, - cmfs: Dict[int, Tuple[float, float, float]] = cmfs.CIE_1931_2DEG, + cmfs: dict[int, tuple[float, float, float]] = cmfs.CIE_1931_2DEG, white: VectorLike = cat.WHITES['2deg']['D65'], mired: VectorLike = MIRED_EXTENDED, sigfig: int = 5, @@ -46,12 +47,12 @@ def __init__( def generate_table( self, - cmfs: Dict[int, Tuple[float, float, float]], + cmfs: dict[int, tuple[float, float, float]], white: VectorLike, mired: VectorLike, sigfig: int, planck_step: int, - ) -> List[Tuple[float, float, float, float]]: + ) -> list[tuple[float, float, float, float]]: """ Generate the necessary table for the Robertson1968 method. @@ -69,7 +70,7 @@ def generate_table( """ xyzw = util.xy_to_xyz(white) - table = [] # type: List[Tuple[float, float, float, float]] + table = [] # type: list[tuple[float, float, float, float]] to_uv = util.xy_to_uv_1960 if self.CHROMATICITY == 'uv-1960' else util.xy_to_uv for t in mired: uv1 = to_uv(planck.temp_to_xy_planckian_locus(1e6 / (t - 0.01), cmfs, xyzw, step=planck_step)) @@ -99,7 +100,7 @@ def generate_table( table.append((t, uv[0], uv[1], m)) return table - def to_cct(self, color: 'Color', **kwargs: Any) -> Vector: + def to_cct(self, color: Color, **kwargs: Any) -> Vector: """Calculate a color's CCT.""" u, v = color.split_chromaticity(self.CHROMATICITY)[:-1] @@ -132,7 +133,7 @@ def to_cct(self, color: 'Color', **kwargs: Any) -> Vector: # Calculate the temperature, if the mired value is zero # assume the maximum temperature of 100000K. mired = alg.lerp(previous[0], current[0], factor) - temp = 1.0E6 / mired if mired > 0 else alg.inf + temp = 1.0E6 / mired if mired > 0 else math.inf # Interpolate the slope vectors dup = 1 / previous_denom @@ -160,14 +161,14 @@ def to_cct(self, color: 'Color', **kwargs: Any) -> Vector: def from_cct( self, - color: Type['Color'], + color: type[Color], space: str, kelvin: float, duv: float, scale: bool, - scale_space: Optional[str], + scale_space: str | None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Calculate a color that satisfies the CCT.""" # Find inverse temperature to use as index. diff --git a/lib/coloraide/types.py b/lib/coloraide/types.py index c7ae45a1..26590608 100644 --- a/lib/coloraide/types.py +++ b/lib/coloraide/types.py @@ -1,4 +1,5 @@ """Typing.""" +from __future__ import annotations from typing import Union, Any, Mapping, Sequence, List, Tuple, TypeVar, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover @@ -9,12 +10,25 @@ # Vectors, Matrices, and Arrays are assumed to be mutable lists Vector = List[float] Matrix = List[Vector] -Array = Union[Matrix, Vector] +Tensor = List[List[List[Union[float, Any]]]] +Array = Union[Matrix, Vector, Tensor] # Anything that resembles a sequence will be considered "like" one of our types above VectorLike = Sequence[float] MatrixLike = Sequence[VectorLike] -ArrayLike = Union[VectorLike, MatrixLike] +TensorLike = Sequence[Sequence[Sequence[Union[float, Any]]]] +ArrayLike = Union[VectorLike, MatrixLike, TensorLike] + +# Vectors, Matrices, and Arrays of various, specific types +VectorBool = List[bool] +MatrixBool = List[VectorBool] +TensorBool = List[List[List[Union[bool, Any]]]] +ArrayBool = Union[MatrixBool, VectorBool, TensorBool] + +VectorInt = List[int] +MatrixInt = List[VectorInt] +TensorInt = List[List[List[Union[int, Any]]]] +ArrayInt = Union[MatrixInt, VectorInt, TensorInt] # General algebra types Shape = Tuple[int, ...] @@ -24,7 +38,7 @@ # For times when we must explicitly say we support `int` and `float` SupportsFloatOrInt = TypeVar('SupportsFloatOrInt', float, int) -MathType= TypeVar('MathType', float, VectorLike, MatrixLike) +MathType = TypeVar('MathType', float, VectorLike, MatrixLike, TensorLike) class Plugin: diff --git a/lib/coloraide/util.py b/lib/coloraide/util.py index 51fd6fd2..1d4b896d 100644 --- a/lib/coloraide/util.py +++ b/lib/coloraide/util.py @@ -1,7 +1,10 @@ """Utilities.""" +from __future__ import annotations import math +from functools import wraps from . import algebra as alg from .types import Vector, VectorLike +from typing import Any, Callable DEF_PREC = 5 DEF_FIT_TOLERANCE = 0.000075 @@ -16,13 +19,7 @@ DEF_CHROMATIC_ADAPTATION = "bradford" DEF_CONTRAST = "wcag21" DEF_CCT = "robertson-1968" - -# Maximum luminance in PQ is 10,000 cd/m^2 -# Relative XYZ has Y=1 for media white -# BT.2048 says media white Y=203 at PQ 58 -# -# This is confirmed here: https://www.itu.int/dms_pub/itu-r/opb/rep/R-REP-BT.2408-3-2019-PDF-E.pdf -YW = 203 +DEF_INTERPOLATOR = "linear" # PQ Constants # https://en.wikipedia.org/wiki/High-dynamic-range_video#Perceptual_quantizer @@ -112,9 +109,28 @@ def pq_st2084_oetf( adjusted = [] for c in values: - c = alg.npow(c / 10000, m1) - r = (c1 + c2 * c) / (1 + c3 * c) - adjusted.append(alg.npow(r, m2)) + c = alg.spow(c / 10000, m1) + adjusted.append(alg.spow((c1 + c2 * c) / (1 + c3 * c), m2)) + return adjusted + + +def pq_st2084_eotf( + values: VectorLike, + c1: float = C1, + c2: float = C2, + c3: float = C3, + m1: float = M1, + m2: float = M2 +) -> Vector: + """Perceptual quantizer (SMPTE ST 2084) - EOTF.""" + + im1 = 1 / m1 + im2 = 1 / m2 + + adjusted = [] + for c in values: + c = alg.spow(c, im2) + adjusted.append(10000 * alg.spow(max((c - c1), 0) / (c2 - c3 * c), im1)) return adjusted @@ -138,37 +154,28 @@ def rgb_scale(vec: VectorLike) -> Vector: return [v / m if m else v for v in vec] -def pq_st2084_eotf( - values: VectorLike, - c1: float = C1, - c2: float = C2, - c3: float = C3, - m1: float = M1, - m2: float = M2 -) -> Vector: - """Perceptual quantizer (SMPTE ST 2084) - EOTF.""" +def scale100(coords: Vector) -> Vector: + """Scale from 1 to 100.""" - im1 = 1 / m1 - im2 = 1 / m2 + return [c * 100 for c in coords] - adjusted = [] - for c in values: - c = alg.npow(c, im2) - r = (c - c1) / (c2 - c3 * c) - adjusted.append(10000 * alg.npow(r, im1)) - return adjusted + +def scale1(coords: Vector) -> Vector: + """Scale from 1 to 100.""" + + return [c * 0.01 for c in coords] -def xyz_to_absxyz(xyzd65: VectorLike, yw: float = YW) -> Vector: +def xyz_to_absxyz(xyzd65: VectorLike, yw: float = 100) -> Vector: """XYZ to Absolute XYZ.""" - return [max(c * yw, 0) for c in xyzd65] + return [c * yw for c in xyzd65] -def absxyz_to_xyz(absxyzd65: VectorLike, yw: float = YW) -> Vector: +def absxyz_to_xyz(absxyzd65: VectorLike, yw: float = 100) -> Vector: """Absolute XYZ to XYZ.""" - return [max(c / yw, 0) for c in absxyzd65] + return [c / yw for c in absxyzd65] def constrain_hue(hue: float) -> float: @@ -200,5 +207,30 @@ def fmt_float(f: float, p: int = 0, percent: float = 0.0, offset: float = 0.0) - value = alg.round_to((f + offset) / (percent * 0.01) if percent else f, p) if p == -1: - p = 17 # double precision - return ('{{:{}{}g}}{}'.format('' if abs(value) >= 10 ** p else '.', p, '%' if percent else '')).format(value) + p = 17 # ~double precision + + # Calculate actual print decimal precision + whole = int(value) + if whole: + whole = int(math.log10(-whole if whole < 0 else whole)) + 1 + if p: + p = max(p - whole, 0) + + # Format the string + s = '{{:{}{}f}}'.format('' if whole >= p else '.', p).format(value).rstrip('0').rstrip('.') + return s + '%' if percent else s + + +def debug(func: Callable[..., Any]) -> Callable[..., Any]: # pragma: no cover + """Intercept function call and print arguments and results.""" + + @wraps(func) + def _wrapper(*args: Any, **kwargs: Any) -> Any: + """Print debug information about the function.""" + + print(f" Calling '{func.__name__}' with args={args} and kwargs={kwargs}") + result = func(*args, **kwargs) + print(f" '{func.__name__}' returned {result}") + return result + + return _wrapper diff --git a/tox.ini b/tox.ini index 1cbbd8ee..5dfa779e 100644 --- a/tox.ini +++ b/tox.ini @@ -28,6 +28,6 @@ commands= flake8 "{toxinidir}" [flake8] -ignore=D202,D203,D401,W504,E741,N818 +ignore=D202,D203,D401,W504,E741,N818,A005 max-line-length=120 exclude=site/*.py,.tox/*,lib/coloraide/*,lib/coloraide_extras