diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a90f871..dcc1b83 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,7 @@ on: push: tags: - 'st3-*' + - 'st4-*' jobs: diff --git a/.python-version b/.python-version index 98fccd6..24ee5b1 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.8 \ No newline at end of file +3.13 diff --git a/CHANGES.md b/CHANGES.md index c274952..98c9117 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,11 @@ # ColorHelper +## 6.5.0 + +- **NEW**: Upgrade ColorAide to 6.0.0. +- **NEW**: Allow setting gamut map parameters in settings file. +- **NEW**: Require Python 3.13 for Sublim Text 4201+. + ## 6.4.5 - **FIX**: Fix SCSS support. diff --git a/ch_mixin.py b/ch_mixin.py index baefc72..491b233 100644 --- a/ch_mixin.py +++ b/ch_mixin.py @@ -23,7 +23,10 @@ def setup_gamut_style(self): ch_settings = sublime.load_settings('color_helper.sublime-settings') self.show_out_of_gamut_preview = ch_settings.get('show_out_of_gamut_preview', True) self.gamut_space = ch_settings.get('gamut_space', 'srgb') - self.gamut_map = ch_settings.get('gamut_map', 'lch-chroma') + gmap = ch_settings.get('gamut_map', {'method': 'minde-chroma', 'pspace': 'lch-d65'}) + if isinstance(gmap, str): + gmap = {'method': gmap} + self.gamut_map = gmap if self.gamut_space not in GAMUT_SPACES: self.gamut_space = 'srgb' @@ -35,7 +38,7 @@ def setup_image_border(self): if border_color is not None: try: border_color = self.base(border_color) - border_color.fit(self.gamut_space, method=self.gamut_map) + border_color.fit(self.gamut_space, **self.gamut_map) except Exception: border_color = None @@ -188,7 +191,7 @@ def get_preview(self, color): if not color.in_gamut(check_space): message = 'preview out of gamut' if self.show_out_of_gamut_preview: - pcolor = color.convert(self.gamut_space, fit=self.gamut_map) + pcolor = color.convert(self.gamut_space).fit(**self.gamut_map) preview1 = pcolor.clone().set('alpha', 1) preview2 = pcolor else: @@ -196,7 +199,7 @@ def get_preview(self, color): preview2 = self.out_of_gamut preview_border = self.out_of_gamut_border else: - pcolor = color.convert(self.gamut_space, fit=self.gamut_map) + pcolor = color.convert(self.gamut_space).fit(**self.gamut_map) preview1 = pcolor.clone().set('alpha', 1) preview2 = pcolor diff --git a/ch_panel.py b/ch_panel.py index 4f73115..c20ffe7 100755 --- a/ch_panel.py +++ b/ch_panel.py @@ -245,7 +245,7 @@ def color_picker(self, color): if self.os_color_picker: self.view.hide_popup() - new_color = native_picker(self.base(color).convert("srgb", fit=self.gamut_map)) + new_color = native_picker(self.base(color).convert("srgb").fit(**self.gamut_map)) if new_color is not None: sublime.set_timeout( lambda c=new_color.to_string(**util.COLOR_FULL_PREC): self.view.run_command( diff --git a/ch_picker.py b/ch_picker.py index 2f65ff8..d488ae7 100644 --- a/ch_picker.py +++ b/ch_picker.py @@ -69,7 +69,7 @@ def setup(self, color, mode, controls, on_done, on_cancel): self.setup_controls(controls) color.convert(self.mode, in_place=True) if not color.in_gamut(): - color.fit(method=self.gamut_map) + color.fit(**self.gamut_map) else: color.clip() # Ensure hue is between 0 - 360. diff --git a/ch_preview.py b/ch_preview.py index e82d135..3e0bdcd 100644 --- a/ch_preview.py +++ b/ch_preview.py @@ -271,7 +271,10 @@ def setup_gamut_options(self): self.show_out_of_gamut_preview = ch_settings.get('show_out_of_gamut_preview', True) self.gamut_space = ch_settings.get('gamut_space', 'srgb') - self.gamut_map = ch_settings.get('gamut_map', 'lch-chroma') + gmap = ch_settings.get('gamut_map', {'method': 'minde-chroma', 'pspace': 'lch-d65'}) + if isinstance(gmap, str): + gmap = {'method': gmap} + self.gamut_map = gmap if self.gamut_space not in util.GAMUT_SPACES: self.gamut_space = 'srgb' self.out_of_gamut = self.base("transparent").convert(self.gamut_space) @@ -437,7 +440,7 @@ def do_search(self, force=False): mdpopups.scope2style(self.view, self.view.scope_name(pt))['background'] ).convert("hsl") hsl['lightness'] = hsl['lightness'] + (0.3 if hsl.luminance() < 0.5 else -0.3) - preview_border = hsl.convert(self.gamut_space, fit=self.gamut_map).set('alpha', 1) + preview_border = hsl.convert(self.gamut_space).fit(**self.gamut_map).set('alpha', 1) color = self.base(obj.color) title = '' @@ -448,7 +451,7 @@ def do_search(self, force=False): if not color.in_gamut(check_space): title = ' title="Preview out of gamut"' if self.show_out_of_gamut_preview: - pcolor = color.convert(self.gamut_space, fit=self.gamut_map) + pcolor = color.convert(self.gamut_space).fit(**self.gamut_map) preview1 = pcolor.clone().set('alpha', 1) preview2 = pcolor else: @@ -456,7 +459,7 @@ def do_search(self, force=False): preview2 = self.out_of_gamut preview_border = self.out_of_gamut_border else: - pcolor = color.convert(self.gamut_space, fit=self.gamut_map) + pcolor = color.convert(self.gamut_space).fit(**self.gamut_map) preview1 = pcolor.clone().set('alpha', 1) preview2 = pcolor diff --git a/ch_tool_blend.py b/ch_tool_blend.py index 9f51d18..fb77493 100644 --- a/ch_tool_blend.py +++ b/ch_tool_blend.py @@ -185,10 +185,10 @@ def preview(self, text): else: check_space = self.gamut_space if not pcolor.in_gamut(check_space): - pcolor.fit(self.gamut_space, method=self.gamut_map) + pcolor.fit(self.gamut_space, **self.gamut_map) message = '
* preview out of gamut' color_string = "Gamut Mapped: {}
".format(pcolor.to_string()) - pcolor.convert(self.gamut_space, fit=self.gamut_map, in_place=True) + pcolor.convert(self.gamut_space, in_place=True).fit(**self.gamut_map) color_string += "Color: {}".format(color.to_string(**util.DEFAULT)) preview = pcolor.clone().set('alpha', 1) preview_alpha = pcolor diff --git a/ch_tool_colormod.py b/ch_tool_colormod.py index cff360f..000d5f2 100644 --- a/ch_tool_colormod.py +++ b/ch_tool_colormod.py @@ -106,7 +106,7 @@ def preview(self, text): check_space = self.gamut_space if not pcolor.in_gamut(check_space): message = '
* preview out of gamut' - pcolor.convert(self.gamut_space, fit=self.gamut_map, in_place=True) + pcolor.convert(self.gamut_space, in_place=True).fit(**self.gamut_map) preview = pcolor.clone().set('alpha', 1) preview_alpha = pcolor preview_border = self.default_border diff --git a/ch_tool_contrast.py b/ch_tool_contrast.py index 4bc9ae4..f344bf8 100644 --- a/ch_tool_contrast.py +++ b/ch_tool_contrast.py @@ -114,11 +114,11 @@ def evaluate(base, string, gamut_map): # Package up the color, or the two reference colors along with the mixed. if first: - colors.append(first.fit('srgb', method=gamut_map)) + colors.append(first.fit('srgb', **gamut_map)) if second: if second[-1] < 1.0: second[-1] = 1.0 - colors.append(second.fit('srgb', method=gamut_map)) + colors.append(second.fit('srgb', **gamut_map)) if ratio: if first[-1] < 1.0: first = first.compose(second, space="srgb", out_space=first.space()) diff --git a/ch_tool_diff.py b/ch_tool_diff.py index d3da7e4..41bf2c6 100644 --- a/ch_tool_diff.py +++ b/ch_tool_diff.py @@ -186,10 +186,10 @@ def preview(self, text): else: check_space = self.gamut_space if not orig.in_gamut(check_space): - orig.fit(self.gamut_space, method=self.gamut_map) + orig.fit(self.gamut_space, **self.gamut_map) message = '
* preview out of gamut' color_string = "Gamut Mapped: {}
".format(orig.to_string()) - orig.convert(self.gamut_space, fit=self.gamut_map, in_place=True) + orig.convert(self.gamut_space, in_place=True).fit(**self.gamut_map) color_string += "Color: {}".format(color.to_string(**util.DEFAULT)) preview = orig.clone().set('alpha', 1) preview_alpha = orig diff --git a/ch_tool_edit.py b/ch_tool_edit.py index a67b591..7efce54 100644 --- a/ch_tool_edit.py +++ b/ch_tool_edit.py @@ -222,10 +222,10 @@ def preview(self, text): else: check_space = self.gamut_space if not pcolor.in_gamut(check_space): - pcolor.fit(self.gamut_space, method=self.gamut_map) + pcolor.fit(self.gamut_space, **self.gamut_map) message = '
* preview out of gamut' color_string = "Gamut Mapped: {}
".format(pcolor.to_string()) - pcolor.convert(self.gamut_space, fit=self.gamut_map, in_place=True) + pcolor.convert(self.gamut_space, in_place=True).fit(**self.gamut_map) color_string += "Color: {}".format(color.to_string(**util.DEFAULT)) preview = pcolor.clone().set('alpha', 1) preview_alpha = pcolor diff --git a/color_helper.sublime-settings b/color_helper.sublime-settings index 297abd4..f07d1ff 100755 --- a/color_helper.sublime-settings +++ b/color_helper.sublime-settings @@ -117,8 +117,10 @@ "gamut_space": "srgb", // Gamut mapping approach - // Supported methods are: `lch-chroma`, `oklch-chroma`, `oklch-raytrace`, and `clip` (default). + // Supported methods are: `lch-chroma`, `oklch-chroma`, `raytrace`, and `clip` (default). // `lch-chroma` was the original default before this was configurable. + // If you need to set options within the gamut mapping, you can use a dictionary: + // `{"method": "raytrace", "pspace": "oklch"}` "gamut_map": "clip", ////////////////// @@ -135,10 +137,17 @@ // "ColorHelper.lib.coloraide.spaces.acescc.ACEScc", // "ColorHelper.lib.coloraide.spaces.acescg.ACEScg", // "ColorHelper.lib.coloraide.spaces.acescct.ACEScct", - // "ColorHelper.lib.coloraide.spaces.cam16_jmh.CAM16JMh", + // "ColorHelper.lib.coloraide.spaces.cam16.CAM16JMh", // "ColorHelper.lib.coloraide.spaces.cam16_ucs.CAM16UCS", // "ColorHelper.lib.coloraide.spaces.cam16_ucs.CAM16SCD", // "ColorHelper.lib.coloraide.spaces.cam16_ucs.CAM16LCD", + // "ColorHelper.lib.coloraide.spaces.cam02.CAM02JMh", + // "ColorHelper.lib.coloraide.spaces.cam02_ucs.CAM02UCS", + // "ColorHelper.lib.coloraide.spaces.cam02_ucs.CAM02SCD", + // "ColorHelper.lib.coloraide.spaces.cam02_ucs.CAM02LCD", + // "ColorHelper.lib.coloraide.spaces.zcam.ZCAMJMh", + // "ColorHelper.lib.coloraide.spaces.hellwig.HellwigJMh", + // "ColorHelper.lib.coloraide.spaces.hellwig.HellwigHKJMh", // "ColorHelper.lib.coloraide.spaces.cmy.CMY", // "ColorHelper.lib.coloraide.spaces.cmyk.CMYK", // "ColorHelper.lib.coloraide.spaces.din99o.DIN99o", @@ -146,11 +155,11 @@ // "ColorHelper.lib.coloraide.spaces.hpluv.HPLuv", // "ColorHelper.lib.coloraide.spaces.hsi.HSI", // "ColorHelper.lib.coloraide.spaces.hunter_lab.HunterLab", - // "ColorHelper.lib.coloraide.spaces.ictcp.ICtCp", + // "ColorHelper.lib.coloraide.spaces.ictcp.css.ICtCp", // "ColorHelper.lib.coloraide.spaces.igtgpg.IgTgPg", // "ColorHelper.lib.coloraide.spaces.ipt.IPT", - // "ColorHelper.lib.coloraide.spaces.jzazbz.Jzazbz", - // "ColorHelper.lib.coloraide.spaces.jzczhz.JzCzhz", + // "ColorHelper.lib.coloraide.spaces.jzazbz.css.Jzazbz", + // "ColorHelper.lib.coloraide.spaces.jzczhz.css.JzCzhz", // "ColorHelper.lib.coloraide.spaces.lch99o.LCh99o", // "ColorHelper.lib.coloraide.spaces.orgb.oRGB", // "ColorHelper.lib.coloraide.spaces.prismatic.Prismatic", @@ -160,6 +169,8 @@ // "ColorHelper.lib.coloraide.spaces.ryb.RYB", // "ColorHelper.lib.coloraide.spaces.xyb.XYB", // "ColorHelper.lib.coloraide.spaces.xyy.xyY", + // "ColorHelper.lib.coloraide.spaces.oklrab.Oklrab", + // "ColorHelper.lib.coloraide.spaces.oklrch.OkLrCh", "ColorHelper.lib.coloraide.spaces.hsluv.HSLuv", "ColorHelper.lib.coloraide.spaces.lchuv.LChuv", "ColorHelper.lib.coloraide.spaces.luv.Luv", @@ -220,7 +231,7 @@ "color_classes": { "css-level-4": { "filters": [ - "srgb", "hsl", "hwb", "lch", "lab", "display-p3", "rec2020", + "srgb", "hsl", "hwb", "lch", "lab", "display-p3", "display-p3-linear", "rec2020", "prophoto-rgb", "a98-rgb", "xyz-d65", "xyz-d50", "srgb-linear", "oklab", "oklch" ], @@ -231,6 +242,7 @@ {"space": "hwb", "format": {"comma": false}}, {"space": "a98-rgb", "format": {}}, {"space": "display-p3", "format": {}}, + {"space": "display-p3-linear", "format": {}}, {"space": "prophoto-rgb", "format": {}}, {"space": "rec2020", "format": {}}, {"space": "srgb-linear", "format": {}}, diff --git a/dependencies.json b/dependencies.json index 4b37de3..4493062 100644 --- a/dependencies.json +++ b/dependencies.json @@ -1,8 +1,7 @@ { "*": { ">=3124": [ - "mdpopups", - "typing" + "mdpopups" ] } } diff --git a/docs/src/markdown/settings/previews.md b/docs/src/markdown/settings/previews.md index fff8d1a..659971c 100644 --- a/docs/src/markdown/settings/previews.md +++ b/docs/src/markdown/settings/previews.md @@ -88,8 +88,10 @@ else in the code. ```js // Gamut mapping approach - // Supported methods are: `lch-chroma`, `oklch-chroma`, `oklch-raytrace`, and `clip` (default). + // Supported methods are: `lch-chroma`, `oklch-chroma`, `raytrace`, and `clip` (default). // `lch-chroma` was the original default before this was configurable. + // If you need to set options within the gamut mapping, you can use a dictionary: + // `{"method": "raytrace", "pspace": "oklch"}` "gamut_map": "clip", ``` diff --git a/lib/coloraide/__meta__.py b/lib/coloraide/__meta__.py index 181307f..4a41d7e 100644 --- a/lib/coloraide/__meta__.py +++ b/lib/coloraide/__meta__.py @@ -93,7 +93,7 @@ def __new__( raise ValueError("All version parts except 'release' should be integers.") if release not in REL_MAP: - raise ValueError("'{}' is not a valid release type.".format(release)) + raise ValueError(f"'{release}' is not a valid release type.") # Ensure valid pre-release (we do not allow implicit pre-releases). if ".dev-candidate" < release < "final": @@ -145,15 +145,15 @@ def _get_canonical(self) -> str: # Assemble major, minor, micro version and append `pre`, `post`, or `dev` if needed.. if self.micro == 0 and self.major != 0: - ver = "{}.{}".format(self.major, self.minor) + ver = f"{self.major}.{self.minor}" else: - ver = "{}.{}.{}".format(self.major, self.minor, self.micro) + ver = f"{self.major}.{self.minor}.{self.micro}" if self._is_pre(): - ver += '{}{}'.format(REL_MAP[self.release], self.pre) + ver += f'{REL_MAP[self.release]}{self.pre}' if self._is_post(): - ver += ".post{}".format(self.post) + ver += f".post{self.post}" if self._is_dev(): - ver += ".dev{}".format(self.dev) + ver += f".dev{self.dev}" return ver @@ -164,7 +164,7 @@ def parse_version(ver: str) -> Version: m = RE_VER.match(ver) if m is None: - raise ValueError("'{}' is not a valid version".format(ver)) + raise ValueError(f"'{ver}' is not a valid version") # Handle major, minor, micro major = int(m.group('major')) @@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version: return Version(major, minor, micro, release, pre, post, dev) -__version_info__ = Version(3, 3, 1, "final") +__version_info__ = Version(6, 0, 0, "final") __version__ = __version_info__._get_canonical() diff --git a/lib/coloraide/algebra.py b/lib/coloraide/algebra.py index 38eb7bf..f594781 100644 --- a/lib/coloraide/algebra.py +++ b/lib/coloraide/algebra.py @@ -3,44 +3,49 @@ Includes various math related functions to aid in color translation and manipulation. -Matrix method APIs are implemented often to mimic the familiar `numpy` library or `scipy`. +Matrix method APIs are implemented often to mimic the familiar Numpy library or SciPy. The API for a given function may look very similar to those found in either of the two scientific libraries. Our intent is not implement a full matrix library, but mainly the parts that are most useful for what we do with colors. Functions may not have all the -features as found in the aforementioned libraries, but the API should be similar. +features as found in the aforementioned libraries, and the returns may may vary in format, +and it also not guaranteed the algorithms behind the scene are identical, but the API should +be similar. -We actually really like `numpy`, and have only done this to keep dependencies lightweight -and available on non C Python based implementations. If we ever decide to switch to `numpy`, -we should be able to relatively easily as most of our API is modeled after `numpy` or `scipy`. +We actually really like Numpy and SciPy, and have only done this to keep dependencies lightweight +and available on non C Python based implementations. -Some liberties are taken here and there. For instance, we are not as fast as `numpy`, so -we add some shortcuts to things that are used a lot (`dot`, `multiply`, `divide`, etc.). -In these cases, we provide new input to instruct the operation as to the dimensions of the -matrix so we don't waste time analyzing the matrix. - -There is no requirement that external plugins need to use `algebra`, `numpy` could be -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. +There is no requirement that external plugins need to use `algebra` and Numpy and SciPy could +used as long as the final results are converted to normal types. """ from __future__ import annotations +import builtins +import decimal +import sys +import cmath import math import operator import functools import itertools as it from .deprecate import deprecated from .types import ( - ArrayLike, MatrixLike, VectorLike, TensorLike, Array, Matrix, Tensor, Vector, VectorBool, MatrixBool, TensorBool, - MatrixInt, MathType, Shape, ShapeLike, DimHints, SupportsFloatOrInt + ArrayLike, MatrixLike, EmptyShape, VectorShape, MatrixShape, TensorShape, ArrayShape, VectorLike, + TensorLike, Array, Matrix, Tensor, Vector, VectorBool, MatrixBool, TensorBool, MatrixInt, ArrayType, VectorInt, # noqa: F401 + Shape, DimHints, SupportsFloatOrInt ) from typing import Callable, Sequence, Iterator, Any, Iterable, overload +EPS = sys.float_info.epsilon +RTOL = 4 * EPS +ATOL = 1e-12 NaN = math.nan INF = math.inf +MAX_10_EXP = sys.float_info.max_10_exp +MIN_FLOAT = sys.float_info.min # Keeping for backwards compatibility prod = math.prod -_all = all -_any = any +_all = builtins.all +_any = builtins.any # Shortcut for math operations # Specify one of these in divide, multiply, dot, etc. @@ -48,74 +53,138 @@ # to take. # # `SC` = scalar, `D1` = 1-D array or vector, `D2` = 2-D -# matrix, and `DN_DM` means an N-D and M-D matrix. +# matrix, and `DN` is N-D matrix, which could be of any size, +# even greater than 2-D. # # If just a single specifier is used, it is assumed that # the operation is performed against another of the same. # `SC` = scalar and a scalar, while `SC_D1` means a scalar # and a vector +# +# For any combination with an N-D matrix, you can just use ND as +# we must determine the shape of the N-D matrix anyway in order +# to process it, so checking the shape cannot be avoided. SC = (0, 0) D1 = (1, 1) D2 = (2, 2) +DN = (-1, -1) SC_D1 = (0, 1) SC_D2 = (0, 2) D1_SC = (1, 0) D1_D2 = (1, 2) D2_SC = (2, 0) D2_D1 = (2, 1) -DN_DM = None +DN_DM = (-1, -1) # Vector used to create a special matrix used in natural splines M141 = [1, 4, 1] +# QR decomposition modes +QR_MODES = {'reduced', 'complete', 'r', 'raw'} + ################################ # General math ################################ +def sign(x: float) -> float: + """Return the sign of a given value.""" + + if x and x == x: + return x / abs(x) + return x + + def order(x: float) -> int: """Get the order of magnitude of a number.""" - if x == 0: - return 0 - return math.floor(math.log10(abs(x))) + _, digits, exponent = decimal.Decimal(x).as_tuple() + return len(digits) + int(exponent) - 1 def round_half_up(n: float, scale: int = 0) -> float: """Round half up.""" + if not isinstance(scale, int): + raise ValueError("'float' object cannot be interpreted as an integer") + + # Generally, Python reports the minimum float as 2.2250738585072014e-308, + # but there are outliers as small as 5e-324. `mult` is limited by a scale of 308 + # due to overflow, but we could calculate greater values by splitting the `mult` + # factor into two smaller factors when the scale exceeds 308. This would allow us + # to round out to 324 decimal places for really small values like 5e-324, but + # these values simply aren't practical enough to warrant the extra effort. mult = 10.0 ** scale return math.floor(n * mult + 0.5) / mult -def round_to(f: float, p: int = 0, half_up: bool = True) -> float: - """Round to the specified precision using "half up" rounding.""" +def _round_location( + f: float, + p: int = 0, + mode: str = 'digits' +) -> tuple[int, int]: + """Return the start of the first significant digit and the digit targeted for rounding.""" + + # Round to number of digits + if mode == 'digits': + # Less than zero we assume double precision + if p < 0: + p = 17 + d = p + # If zero, assume integer rounding + if p == 0: + p = 17 + + # Round to decimal place + elif mode == 'decimal': + d = p + p = MAX_10_EXP + + # Round of significant digits + elif mode == 'sigfig': + d = MAX_10_EXP + # Less than zero we assume double precision + if p < 0 or p > 17: + p = 17 + # If zero, assume integer rounding + elif p == 0: + p = 17 + d = 0 + + else: + raise ValueError("Unknown rounding mode '{mode}'") + + if f == 0 or not math.isfinite(f): + return 0, 0 + + # Round to specified significant figure or fractional digit, which ever is less + v = -math.floor(math.log10(abs(f))) + p = v + (p - 1) + return v, d if d < p else p - _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) +def round_to( + f: float, + p: int = 0, + mode: str = 'digits', + rounding: Callable[[float, int], float]=round_half_up +) -> float: + """Round to the specified precision using "half up" rounding by default.""" - # Integer rounding - if p == 0: - return _round(f) + _, p = _round_location(f, p, mode) - # Ignore infinity - if math.isinf(f): + # Return non-finite values without further processing + if not math.isfinite(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(whole if digits > p else f, p - digits) + # Round to the specified location using the specified rounding function + return rounding(f, p) def minmax(value: VectorLike | Iterable[float]) -> tuple[float, float]: """Return the minimum and maximum value.""" - mn = INF - mx = -INF + mn = math.inf + mx = -math.inf e = -1 for i in value: @@ -136,7 +205,7 @@ def clamp( mn: SupportsFloatOrInt | None = None, mx: SupportsFloatOrInt | None = None ) -> SupportsFloatOrInt: - """Clamp the value to the the given minimum and maximum.""" + """Clamp the value to the given minimum and maximum.""" if mn is not None and mx is not None: return max(min(value, mx), mn) @@ -148,11 +217,11 @@ def clamp( return value -def zdiv(a: float, b: float) -> float: +def zdiv(a: float, b: float, default: float = 0.0) -> float: """Protect against zero divide.""" if b == 0: - return 0.0 + return default return a / b @@ -181,27 +250,275 @@ def spow(base: float, exp: float) -> float: return math.copysign(abs(base) ** exp, base) -@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) - h = math.degrees(math.atan2(b, a)) % 360 - return c, h + return math.sqrt(a * a + b * b), math.degrees(math.atan2(b, a)) % 360 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)) - b = c * math.sin(math.radians(h)) - return a, b + r = math.radians(h) + return c * math.cos(r), c * math.sin(r) + + +def solve_bisect( + low:float, + high: float, + f: Callable[..., float], + args: tuple[Any] | tuple[()] = (), + start: float | None = None, + maxiter: int = 50, + rtol: float = RTOL, + atol: float = ATOL, +) -> tuple[float, bool]: + """ + Apply the bisect method to converge upon an answer. + + Return the best answer based on the specified limits and also + return a boolean indicating if we confidently converged. + """ + + t = (high + low) * 0.5 if start is None else start + + x = math.nan + for _ in range(maxiter): + x = f(t, *args) if args else f(t) + if math.isclose(x, 0, rel_tol=rtol, abs_tol=atol): + return t, True + if x > 0: + high = t + else: + low = t + t = (high + low) * 0.5 + + if math.isclose(low, high, rel_tol=rtol, abs_tol=atol): # pragma: no cover + break + + return t, abs(x) < atol # pragma: no cover + + +def _solve_quadratic(poly: Vector) -> Vector: + """ + Solve a quadratic equation. + + a - c represent the coefficients of the polynomial and t equals the target value. + + All non-real roots are filtered out at the end. + """ + + a, b, c = poly + + # Scale coefficients by `a` so that `a` is 1 and drops out of future calculations + if a != 1: + b /= a + c /= a + + m = -b * 0.5 + # Calculate the discriminant to determine number of roots and what type + discriminant = m ** 2 - c + # With `a` no longer a factor, we can greatly simplify the traditional quadratic formula + # Solutions: `m +/- (m ** 2 - c) ** (1/2)` + if discriminant < 0: + # No real roots + return [] + elif discriminant > 0: + # Two real roots + return [ + m + cmath.sqrt(discriminant).real, + m - cmath.sqrt(discriminant).real + ] + # Double root + return [m] + + +def _solve_cubic(poly:Vector) -> Vector: + """ + Solve a cubic equation using Cardano's Method. + + a - d represent the coefficients of the polynomial and t equals the target value. + + All non-real roots are filtered out at the end. + + https://en.wikipedia.org/wiki/Cubic_equation#Cardano's_formula + """ + + a, b, c, d = poly + + # Scale coefficients by `a` so that `a` is 1 and drops out of future calculations + if a != 1: + b /= a + c /= a + d /= a + + # Transform equation to a form removing the squared term: `t^3 + pt + q = 0` + p = (3 * c - b ** 2) / 3 + q = (2 * b ** 3 - 9 * b * c + 27 * d) / 27 + + # Calculate the discriminant to determine number of roots and what type + discriminant = (q ** 2 / 4 + p ** 3 / 27) + + # Calculate `t = u^(1/3) + v^(1/3)` + # Cube root must not use `** (1 / 3)` if real. + # Should use `math.cbrt` or some signed power equivalent + # on systems that don't support it. + u3 = -q / 2 + cmath.sqrt(discriminant) + v3 = -q / 2 - cmath.sqrt(discriminant) + u = u3 ** (1 / 3) if u3.imag else nth_root(u3.real, 3) + v = v3 ** (1 / 3) if v3.imag else nth_root(v3.real, 3) + t = u + v + + # Precalculate conversion from `t` back to `x` + # `x = t - b / 3` + k = b / 3 + + # Primitive roots: `pr = (-1 +/- -3 ** (1/2)) / 2 ~= -0.5 +/- 0.8660254037844386j` + # The complex part (`prc`) equivalent calculation: `(0.8660254037844386j) = 3 ** (1/3) / 2j` + prc = cmath.sqrt(3) / 2j + + # We can find the other two roots by multiplying u and v with the primitive roots: + # ``` + # t2 = pr1 * u + pr2 * v + # t3 = pr2 * u + pr1 * v + # ``` + # With some algebraic manipulation and factoring the conversion to `x` + # ``` + # x1 = (v + v) - k + # x2 = -0.5 * (u + v) + (u - v) * prc - k + # x3 = -0.5 * (u + v) - (u - v) * prc - k + # ``` + td = (u - v) + if discriminant > 0: + # One real root + return [(t - k).real] + elif discriminant < 0: + # Three real roots + return [ + (t - k).real, + (-0.5 * t + td * prc - k).real, + (-0.5 * t - td * prc - k).real + ] + # Three real roots, two of which are doubles + return [ + (t - k).real, + (-0.5 * t + td * prc - k).real + ] + + +def solve_poly(poly: Vector) -> Vector: + """ + Solve the given polynomial. + + Currently, only up to 3rd degree polynomials are supported. + """ + + # Remove leading zeros + count = 0 + for pi in poly: + if pi == 0: + count += 1 + continue + break + if count: + poly = poly[count:] + + # Select the appropriate solver + l = len(poly) + if l > 4: + raise ValueError('Polynomials of degrees great than 3 are not currently supported') + elif l == 4: + return _solve_cubic(poly) + elif l == 3: + return _solve_quadratic(poly) + elif l == 2: + return [-poly[1] / poly[0]] + return [] + + +def solve_newton( + x0: float, + f0: Callable[..., float], + dx: Callable[..., float], + dx2: Callable[..., float] | None = None, + args: tuple[Any] | tuple[()] = (), + maxiter: int = 50, + rtol: float = RTOL, + atol: float = ATOL, + ostrowski: bool = False +) -> tuple[float, bool | None]: + """ + Solve equation using Newton's method. + + If the second derivative is given, Halley's method will be used as an additional step. + Newton provides 2nd order convergence and Halley provides 3rd order convergence. + + ``` + newton = yn = xn - f(xn) / f'(xn) + halley = xn - (f(xn) * f'(xn)) / (f'(xn) ** 2 - 0.5 * f(xn) * f''(xn)) + ``` + + Algebraically, we can pull the Newton stop out of the Halley method into two separate steps + that can be applied on top of each other. + + ``` + Step1: yn = f(xn) / f'(xn) + Step2: halley = xn - yn / (1 - 0.5 * yn * f''(xn) / f'(xn)) + ``` + + If Ostrowski method is enabled, only one derivative is needed, but you can get 4th order convergence. + + ``` + yn = xn - f(xn) / f'(xn) + ostrowski = yn - f(xn) / (f(xn) - 2 * f(yn)) * (f(yn) / f'(xn)) + ``` + + Return result along with True if converged, False if did not converge, None if could not converge. + """ + + for _ in range(maxiter): + # Get result form equation when setting value to expected result + fx = f0(x0, *args) if args else f0(x0) + prev = x0 + + # If the result is zero, we've converged + if fx == 0: + return x0, True + + # Cannot find a solution if derivative is zero + d1 = dx(x0, *args) if args else dx(x0) + if d1 == 0: + return x0, None # pragma: no cover + + # Calculate new, hopefully closer value with Newton's method + newton = fx / d1 + + # If second derivative is provided, apply the Halley's method step: 3rd order convergence + if dx2 is not None and not ostrowski: + d2 = dx2(x0, *args) if args else dx2(x0) + value = (0.5 * newton * d2) / d1 + # If the value is greater than one, the solution is deviating away from the newton step + if abs(value) < 1: + newton /= 1 - value + + # If change is under our epsilon, we can consider the result converged. + x0 -= newton + if math.isclose(x0, prev, rel_tol=rtol, abs_tol=atol): + return x0, True # pragma: no cover + # Use Ostrowski method: 4th order convergence + if ostrowski: + fy = f0(x0, *args) if args else f0(x0) + if fy == 0: + return x0, True + fy_x2 = 2 * fy + if fy_x2 == fx: # pragma: no cover + return x0, None + x0 -= fx / (fx - fy_x2) * (fy / d1) + + if math.isclose(x0, prev, rel_tol=rtol, abs_tol=atol): # pragma: no cover + return x0, True + + return x0, False # pragma: no cover ################################ @@ -282,13 +599,13 @@ def ilerp2d( jy = [sum(i) for i in zip(*[[yi * c for c in ci] for ci, yi in zip(vertices_t, _y)])] # Create the Jacobian matrix, but we need it in column form - j = transpose([jx, jy]) + j = [*zip(jx, jy)] # Solve for new guess xy = subtract(xy, solve(j, residual), dims=D1) except ValueError: # pragma: no cover - # The Jacobian matrix shouldn't fail inversion if we are in gamut. - # Out of gamut may give us one we cannot invert. There are potential + # The Jacobian matrix shouldn't fail inversion if we are in range. + # Out of range may give us values we cannot invert. There are potential # ways to handle this to try and get moving again, but currently, we # just give up. We do not guarantee out of gamut conversions. pass @@ -388,7 +705,7 @@ def ilerp3d( # Build up the Jacobian matrix so we can solve for the next, closer guess x, y, z = xyz _x = [ - -(1 - y) * (1 - z), + -(1 - y) * (1 - z), (1 - y) * (1 - z), -y * (1 - z), y * (1 - z), @@ -424,13 +741,13 @@ def ilerp3d( jz = [sum(i) for i in zip(*[[zi * c for c in ci] for ci, zi in zip(vertices_t, _z)])] # Create the Jacobian matrix, but we need it in column form - j = transpose([jx, jy, jz]) + j = [*zip(jx, jy, jz)] # Solve for new guess xyz = subtract(xyz, solve(j, residual), dims=D1) except ValueError: # pragma: no cover - # The Jacobian matrix shouldn't fail inversion if we are in gamut. - # Out of gamut may give us one we cannot invert. There are potential + # The Jacobian matrix shouldn't fail inversion if we are in range. + # Out of range may give us values we cannot invert. There are potential # ways to handle this to try and get moving again, but currently, we # just give up. We do not guarantee out of gamut conversions. pass @@ -474,7 +791,7 @@ def naturalize_bspline_controls(coordinates: list[Vector]) -> None: m = _matrix_141(n) # Create C matrix from the data points - c = [] + c = [] # type: Matrix for r in range(1, n + 1): if r == 1: c.append([a * 6 - b for a, b in zip(coordinates[r], coordinates[r - 1])]) @@ -618,7 +935,7 @@ def __init__( self.length = length self.num_coords = len(points[0]) - self.points = list(zip(*points)) + self.points = [*zip(*points)] self.callback = callback self.linear = linear @@ -724,7 +1041,7 @@ def vdot(a: VectorLike, b: VectorLike) -> float: l = len(a) if l != len(b): - raise ValueError('Vectors of size {} and {} are not aligned'.format(l, len(b))) + raise ValueError(f'Vectors of size {l} and {len(b)} are not aligned') s = 0.0 i = 0 while i < l: @@ -745,7 +1062,7 @@ def vcross(v1: VectorLike, v2: VectorLike) -> Any: # pragma: no cover l1 = len(v1) if l1 != len(v2): - raise ValueError('Incompatible dimensions of {} and {} for cross product'.format(l1, len(v2))) + raise ValueError(f'Incompatible dimensions of {l1} and {len(v2)} for cross product') if l1 == 2: return v1[0] * v2[1] - v1[1] * v2[0] @@ -756,7 +1073,7 @@ def vcross(v1: VectorLike, v2: VectorLike) -> Any: # pragma: no cover v1[0] * v2[1] - v1[1] * v2[0] ] else: - raise ValueError('Expected vectors of shape (2,) or (3,) but got ({},) ({},)'.format(l1, len(v2))) + raise ValueError(f'Expected vectors of shape (2,) or (3,) but got ({l1},) ({len(v2)},)') @overload @@ -781,21 +1098,21 @@ def acopy(a: ArrayLike) -> Array: @overload -def _cross_pad(a: VectorLike, s: Shape) -> Vector: +def _cross_pad(a: VectorLike, s: ArrayShape) -> Vector: ... @overload -def _cross_pad(a: MatrixLike, s: Shape) -> Matrix: +def _cross_pad(a: MatrixLike, s: ArrayShape) -> Matrix: ... @overload -def _cross_pad(a: TensorLike, s: Shape) -> Tensor: +def _cross_pad(a: TensorLike, s: ArrayShape) -> Tensor: ... -def _cross_pad(a: ArrayLike, s: Shape) -> Array: +def _cross_pad(a: ArrayLike, s: ArrayShape) -> Array: """Pad an array with 2-D vectors.""" m = acopy(a) @@ -826,8 +1143,8 @@ def cross(a: ArrayLike, b: ArrayLike) -> Any: """Vector cross product.""" # Determine shape of arrays - shape_a = shape(a) - shape_b = shape(b) + shape_a = shape(a) # type: Shape + shape_b = shape(b) # type: Shape dims_a = len(shape_a) dims_b = len(shape_b) @@ -852,13 +1169,13 @@ def cross(a: ArrayLike, b: ArrayLike) -> Any: 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)) + new_shape = [*_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] + result = [vcross(r, b) for r in a] # type: Any # type: ignore[arg-type] elif dims_b == 2: # Cross a vector and a 2-D matrix @@ -866,139 +1183,153 @@ def cross(a: ArrayLike, b: ArrayLike) -> Any: 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] + m = new_shape[-2] + rows = _extract_rows(a, shape_a) + result = [[vcross(next(rows), b) for _ in range(m)] for _ in range(m)] # type: ignore[arg-type] else: # Cross a vector and an N-D matrix - result = [vcross(a, r) for r in _extract_rows(b, shape_b)] # type: ignore[arg-type] + m = new_shape[-2] + rows = _extract_rows(b, shape_b) + result = [[vcross(a, next(rows)) for _ in range(m)] for _ in range(m)] # type: ignore[arg-type] - return reshape(result, new_shape) + return result # Cross an N-D and M-D matrix bcast = broadcast(a, b) a2 = [] b2 = [] - data = [] count = 1 size = bcast.shape[-1] - for x, y in bcast: - a2.append(x) - b2.append(y) - if count == size: - data.append(vcross(a2, b2)) - a2 = [] - b2 = [] - count = 0 - count += 1 # Adjust shape for the way cross outputs data - new_shape = list(bcast.shape) + new_shape = [*bcast.shape] mdim = max(dims_a, dims_b) if mdim > 1 and new_shape[-1] == 2: new_shape.pop(-1) + _shape = tuple(new_shape) # type: Shape + else: + _shape = tuple(new_shape)[:-1] - return reshape(data, new_shape) + result = [] + with ArrayBuilder(result, _shape) as build: + for x, y in bcast: + a2.append(x) + b2.append(y) + if count == size: + next(build).append(vcross(a2, b2)) + a2 = [] + b2 = [] + count = 0 + count += 1 + return result -def _extract_rows(m: ArrayLike, s: ShapeLike, depth: int = 0) -> Iterator[Vector]: - """Extract rows from an array.""" - if len(s) > 1 and s[1]: - for m1 in m: - yield from _extract_rows(m1, s[1:], depth + 1) # type: ignore[arg-type] - else: - yield m # type: ignore[misc] +def _extract_rows(m: ArrayLike, s: ArrayShape) -> Iterator[Vector]: + """Extract row data from an array.""" + + # Matrix or tensor + for idx in ndindex(s[:-1]): + t = m # type: Any + for i in idx: + t = t[i] + yield t -def _extract_cols(m: ArrayLike, s: ShapeLike, depth: int = 0) -> Iterator[Vector]: - """Extract columns from an array.""" +def _extract_cols(m: ArrayLike, s: ArrayShape) -> Iterator[Vector]: + """Extract column data from an array.""" - if len(s) > 2 and s[2]: - for m1 in m: - yield from _extract_cols(m1, s[1:], depth + 1) # type: ignore[arg-type] - elif not depth: + # Vector (nothing to do) + if len(s) < 2: yield m # type: ignore[misc] + + # M x N matrix else: - yield from [[x[r] for x in m] for r in range(len(m[0]))] # type: ignore[arg-type, index, misc] + for idx in ndindex(s[:-2]): + t = m # type: Any + for i in idx: + t = t[i] + yield from [[r[c] for r in t] for c in range(s[-1])] @overload -def dot(a: float, b: float, *, dims: DimHints | None = ...) -> float: +def dot(a: float, b: float, *, dims: DimHints = ...) -> float: ... @overload -def dot(a: float, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: +def dot(a: float, b: VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def dot(a: VectorLike, b: float, *, dims: DimHints | None = ...) -> Vector: +def dot(a: VectorLike, b: float, *, dims: DimHints = ...) -> Vector: ... @overload -def dot(a: float, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def dot(a: float, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: ... @overload -def dot(a: MatrixLike, b: float, *, dims: DimHints | None = ...) -> Matrix: +def dot(a: MatrixLike, b: float, *, dims: DimHints = ...) -> Matrix: ... @overload -def dot(a: float, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: +def dot(a: float, b: TensorLike, *, dims: DimHints = ...) -> Tensor: ... @overload -def dot(a: TensorLike, b: float, *, dims: DimHints | None = ...) -> Tensor: +def dot(a: TensorLike, b: float, *, dims: DimHints = ...) -> Tensor: ... @overload -def dot(a: VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> float: +def dot(a: VectorLike, b: VectorLike, *, dims: DimHints = ...) -> float: ... @overload -def dot(a: VectorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Vector: +def dot(a: VectorLike, b: MatrixLike, *, dims: DimHints = ...) -> Vector: ... @overload -def dot(a: MatrixLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: +def dot(a: MatrixLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def dot(a: VectorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Matrix: +def dot(a: VectorLike, b: TensorLike, *, dims: DimHints = ...) -> Tensor | Matrix: ... @overload -def dot(a: TensorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Matrix: +def dot(a: TensorLike, b: VectorLike, *, dims: DimHints = ...) -> Tensor | Matrix: ... @overload -def dot(a: MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def dot(a: MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: ... @overload -def dot(a: MatrixLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: +def dot(a: MatrixLike, b: TensorLike, *, dims: DimHints = ...) -> Tensor | Matrix: ... @overload -def dot(a: TensorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: +def dot(a: TensorLike, b: MatrixLike, *, dims: DimHints = ...) -> Tensor | Matrix: ... @overload -def dot(a: TensorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: +def dot(a: TensorLike, b: TensorLike, *, dims: DimHints = ...) -> Tensor: ... @@ -1006,7 +1337,7 @@ def dot( a: float | ArrayLike, b: float | ArrayLike, *, - dims: DimHints | None = None, + dims: DimHints = DN, ) -> float | Array: """ Perform dot product. @@ -1018,34 +1349,39 @@ def dot( or less will act the same as `matmul`. """ - if dims is None or dims[0] > 2 or dims[1] > 2: - shape_a = shape(a) - shape_b = shape(b) + if dims[0] < 0 or dims[1] < 0 or dims[0] > 2 or dims[1] > 2: + shape_a = shape(a) # type: Shape + shape_b = shape(b) # type: Shape 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): + result = [] # type: Matrix | Tensor 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] + with ArrayBuilder(result, shape_b[:-2] + shape_b[-1:]) as build: + for col in _extract_cols(b, shape_b): # type: ignore[arg-type] + next(build).append(vdot(a, col)) # 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] + with ArrayBuilder(result, shape_a[:-1]) as build: + for row in _extract_rows(a, shape_a): # type: ignore[arg-type] + next(build).append(vdot(row, b)) # 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` - - 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:] - ) + cols = [*_extract_cols(b, shape_b)] # type: ignore[arg-type] + n = shape_b[-1] # type: ignore[misc] + with ArrayBuilder(result, shape_a[:-1] + shape_b[:-2]) as build: + for row in _extract_rows(a, shape_a): # type: ignore[arg-type] + r = [sum(multiply(row, col)) for col in cols] + start = 0 + for _ in range(len(r) // n): + end = start + n + next(build).append(r[start:end]) + start = end + return result else: dims_a, dims_b = dims @@ -1058,47 +1394,47 @@ def dot( @overload -def matmul(a: VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> float: +def matmul(a: VectorLike, b: VectorLike, *, dims: DimHints = ...) -> float: ... @overload -def matmul(a: VectorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Vector: +def matmul(a: VectorLike, b: MatrixLike, *, dims: DimHints = ...) -> Vector: ... @overload -def matmul(a: MatrixLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: +def matmul(a: MatrixLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def matmul(a: VectorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Matrix: +def matmul(a: VectorLike, b: TensorLike, *, dims: DimHints = ...) -> Tensor | Matrix: ... @overload -def matmul(a: TensorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Matrix: +def matmul(a: TensorLike, b: VectorLike, *, dims: DimHints = ...) -> Tensor | Matrix: ... @overload -def matmul(a: MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def matmul(a: MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: ... @overload -def matmul(a: MatrixLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: +def matmul(a: MatrixLike, b: TensorLike, *, dims: DimHints = ...) -> Tensor | Matrix: ... @overload -def matmul(a: TensorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: +def matmul(a: TensorLike, b: MatrixLike, *, dims: DimHints = ...) -> Tensor | Matrix: ... @overload -def matmul(a: TensorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: +def matmul(a: TensorLike, b: TensorLike, *, dims: DimHints = ...) -> Tensor: ... @@ -1106,7 +1442,7 @@ def matmul( a: ArrayLike, b: ArrayLike, *, - dims: DimHints | None = None, + dims: DimHints = DN, ) -> float | Array: """ Perform matrix multiplication of two arrays. @@ -1117,36 +1453,39 @@ def matmul( 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) + if dims[0] < 0 or dims[1] < 0 or dims[0] > 2 or dims[1] > 2: + shape_a = shape(a) # type: ArrayShape + shape_b = shape(b) # type: ArrayShape 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): + result = [] # type: Matrix | Tensor 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] + with ArrayBuilder(result, shape_b[:-2] + shape_b[-1:]) as build: + for col in _extract_cols(b, shape_b): + next(build).append(vdot(a, col)) # type: ignore[arg-type] + return result 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] + with ArrayBuilder(result, shape_a[:-1]) as build: + for row in _extract_rows(a, shape_a): + next(build).append(vdot(row, b)) # type: ignore[arg-type] + return result 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) + a = broadcast_to(a, shape_a) # type: ignore[arg-type, assignment] shape_b = common + shape_b[-2:] - b = broadcast_to(b, shape_b) - m2 = [ - matmul(a1, b1, dims=D2) - for a1, b1 in zip(_extract_rows(a, shape_a[:-1]), _extract_rows(b, shape_b[:-1])) - ] - return reshape(m2, common + (shape_a[-2], shape_b[-1])) - + b = broadcast_to(b, shape_b) # type: ignore[arg-type, assignment] + with ArrayBuilder(result, common) as build: + for a1, b1 in it.zip_longest(_extract_rows(a, shape_a[:-1]), _extract_rows(b, shape_b[:-1])): + next(build).append(matmul(a1, b1, dims=D2)) + return result raise ValueError( 'Incompatible shapes in core dimensions (n?,k),(k,m?)->(n?,m?), {} != {}'.format( shape_a[-1], @@ -1171,7 +1510,7 @@ def matmul( return [vdot(row, b) for row in a] # type: ignore[arg-type] elif dims_b == 2: # Matrix multiply of two matrices - cols = list(it.zip_longest(*b)) + cols = [*it.zip_longest(*b)] return [ [vdot(row, col) for col in cols] for row in a # type: ignore[arg-type] ] @@ -1180,7 +1519,177 @@ def matmul( raise ValueError('Inputs require at least 1 dimension, scalars are not allowed') -def _matrix_chain_order(shapes: Sequence[Shape]) -> MatrixInt: +@overload +def matmul_x3(a: VectorLike, b: VectorLike, *, dims: DimHints = ...) -> float: + ... + + +@overload +def matmul_x3(a: VectorLike, b: MatrixLike, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def matmul_x3(a: MatrixLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def matmul_x3(a: MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +def matmul_x3( + a: MatrixLike | VectorLike, + b: MatrixLike | VectorLike, + *, + dims: DimHints = DN, +) -> float | Vector | Matrix: + """ + An optimized version of `matmul` that the total allowed dimensions to <= 2 and constrains dimensions lengths to 3. + + By limited to the total dimensions to < 2 and the dimension lengths of 3, loops are no longer required to handle + an unknown number of dimensions or dimension lengths allowing for more optimized and faster performance at the + cost of being able to handle any size arrays. + + For more flexibility with array sizes, use `matmul`. + """ + + dims_a = dims[0] if dims[0] >= 0 else len(shape(a)) + dims_b = dims[1] if dims[1] >= 0 else len(shape(b)) + + # Optimize to handle arrays <= 2-D + if dims_a == 1: + if dims_b == 1: + # Matrix multiply of two vectors + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] # type: ignore[operator] + elif dims_b == 2: + # Matrix multiply of vector and a matrix + return [ + a[0] * b[0][0] + a[1] * b[1][0] + a[2] * b[2][0], # type: ignore[index, operator] + a[0] * b[0][1] + a[1] * b[1][1] + a[2] * b[2][1], # type: ignore[index, operator] + a[0] * b[0][2] + a[1] * b[1][2] + a[2] * b[2][2] # type: ignore[index, operator] + ] + + elif dims_a == 2: + if dims_b == 1: + # Matrix multiply of matrix and a vector + return [ + a[0][0] * b[0] + a[0][1] * b[1] + a[0][2] * b[2], # type: ignore[index, operator] + a[1][0] * b[0] + a[1][1] * b[1] + a[1][2] * b[2], # type: ignore[index, operator] + a[2][0] * b[0] + a[2][1] * b[1] + a[2][2] * b[2], # type: ignore[index, operator] + ] + elif dims_b == 2: + # Matrix and column vector + if len(b[0]) == 1: # type: ignore[arg-type] + return [ + [ + a[0][0] * b[0][0] + a[0][1] * b[1][0] + a[0][2] * b[2][0], # type: ignore[index] + ], + [ + a[1][0] * b[0][0] + a[1][1] * b[1][0] + a[1][2] * b[2][0], # type: ignore[index] + ], + [ + a[2][0] * b[0][0] + a[2][1] * b[1][0] + a[2][2] * b[2][0], # type: ignore[index] + ] + ] + # Two full matrices + return [ + [ + a[0][0] * b[0][0] + a[0][1] * b[1][0] + a[0][2] * b[2][0], # type: ignore[index] + a[0][0] * b[0][1] + a[0][1] * b[1][1] + a[0][2] * b[2][1], # type: ignore[index] + a[0][0] * b[0][2] + a[0][1] * b[1][2] + a[0][2] * b[2][2] # type: ignore[index] + ], + [ + a[1][0] * b[0][0] + a[1][1] * b[1][0] + a[1][2] * b[2][0], # type: ignore[index] + a[1][0] * b[0][1] + a[1][1] * b[1][1] + a[1][2] * b[2][1], # type: ignore[index] + a[1][0] * b[0][2] + a[1][1] * b[1][2] + a[1][2] * b[2][2] # type: ignore[index] + ], + [ + a[2][0] * b[0][0] + a[2][1] * b[1][0] + a[2][2] * b[2][0], # type: ignore[index] + a[2][0] * b[0][1] + a[2][1] * b[1][1] + a[2][2] * b[2][1], # type: ignore[index] + a[2][0] * b[0][2] + a[2][1] * b[1][2] + a[2][2] * b[2][2] # type: ignore[index] + ] + ] + + # N > 2 dimensions are not allowed + if dims_a > 2 or dims_b > 2: + raise ValueError('Inputs cannot exceed 2 dimensions') + + # Scalars are not allowed + raise ValueError('Inputs require at least 1 dimension, scalars are not allowed') + + +@overload +def dot_x3(a: float, b: float, *, dims: DimHints = ...) -> float: + ... + + +@overload +def dot_x3(a: float, b: VectorLike, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def dot_x3(a: VectorLike, b: float, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def dot_x3(a: float, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +@overload +def dot_x3(a: MatrixLike, b: float, *, dims: DimHints = ...) -> Matrix: + ... + + +@overload +def dot_x3(a: VectorLike, b: VectorLike, *, dims: DimHints = ...) -> float: + ... + + +@overload +def dot_x3(a: VectorLike, b: MatrixLike, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def dot_x3(a: MatrixLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def dot_x3(a: MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +def dot_x3( + a: MatrixLike | VectorLike | float, + b: MatrixLike | VectorLike | float, + dims: DimHints = DN +) -> float | Array: + """ + An optimized version of `dot` that the total allowed dimensions to <= 2 and constrains dimensions lengths to 3. + + By limited to the total dimensions to < 2 and the dimension lengths of 3, loops are no longer required to handle + an unknown number of dimensions or dimension lengths allowing for more optimized and faster performance at the + cost of being able to handle any size arrays. + + For more flexibility with array sizes, use `dot`. + """ + + dims_a = dims[0] if dims[0] >= 0 else len(shape(a)) + dims_b = dims[1] if dims[1] >= 0 else len(shape(b)) + + if not dims_a or not dims_b: + return multiply_x3(a, b, dims=(dims_a, dims_b)) + + return matmul_x3(a, b, dims=(dims_a, dims_b)) # type: ignore[arg-type] + + +def _matrix_chain_order(shapes: Sequence[ArrayShape]) -> MatrixInt: """ Calculate chain order. @@ -1249,18 +1758,17 @@ def multi_dot(arrays: Sequence[ArrayLike]) -> Any: shapes = [shape(a) for a in arrays] # We need the list mutable if we are going to update the entries - if not isinstance(arrays, list): - arrays = list(arrays) + _arrays = [*arrays] if not isinstance(arrays, list) else arrays # type: Any # Row vector if len(shapes[0]) == 1: - arrays[0] = [arrays[0]] + _arrays[0] = [arrays[0]] shapes[0] = (1,) + shapes[0] is_vector = True # Column vector if len(shapes[-1]) == 1: - arrays[-1] = transpose([arrays[-1]]) + _arrays[-1] = transpose([_arrays[-1]]) shapes[-1] = shapes[-1] + (1,) if is_vector: is_scalar = True @@ -1278,15 +1786,15 @@ def multi_dot(arrays: Sequence[ArrayLike]) -> Any: pa = prod(shapes[0]) pc = prod(shapes[2]) cost1 = pa * shapes[2][0] + pc * shapes[0][0] - cost2 = pc * shapes[0][1] + pa * shapes[2][1] + cost2 = pc * shapes[0][1] + pa * shapes[2][1] # type: ignore[misc] if cost1 < cost2: - value = dot(dot(arrays[0], arrays[1], dims=D2), arrays[2], dims=D2) + value = dot(dot(_arrays[0], _arrays[1], dims=D2), _arrays[2], dims=D2) # type: Any else: - value = dot(arrays[0], dot(arrays[1], arrays[2], dims=D2), dims=D2) + value = dot(_arrays[0], dot(_arrays[1], _arrays[2], dims=D2), dims=D2) # Calculate the fastest ordering with dynamic programming using memoization - s = _matrix_chain_order([shape(a) for a in arrays]) - value = _multi_dot(arrays, s, 0, count - 1) + s = _matrix_chain_order([shape(a) for a in _arrays]) + value = _multi_dot(_arrays, s, 0, count - 1) # `numpy` returns the shape differently depending on if there is a row and/or column vector if is_scalar: @@ -1425,7 +1933,7 @@ def __init__( self, arrays: Sequence[ArrayLike | float], shapes: Sequence[Shape], - new: ShapeLike + new: Shape ) -> None: """Initialize.""" @@ -1543,11 +2051,11 @@ def __next__(self) -> tuple[float, ...]: def __iter__(self) -> Iterator[tuple[float, ...]]: # pragma: no cover """Iterate.""" - # Setup and and return the iterator. + # Setup and return the iterator. return self -def _broadcast_shape(shapes: list[Shape], max_dims: int, stage1_shapes: list[Shape] | None = None) -> Shape: +def _broadcast_shape(shapes: Sequence[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 @@ -1627,7 +2135,7 @@ def __next__(self) -> tuple[float, ...]: def __iter__(self) -> Broadcast: """Iterate.""" - # Setup and and return the iterator. + # Setup and return the iterator. return self @@ -1637,31 +2145,57 @@ def broadcast(*arrays: ArrayLike | float) -> Broadcast: return Broadcast(*arrays) -def broadcast_to(a: ArrayLike | float, s: int | ShapeLike) -> Array: - """Broadcast array to a shape.""" +@overload +def broadcast_to(a: ArrayLike | float, s: EmptyShape) -> float: + ... - if not isinstance(s, Sequence): - s = (s,) - if not isinstance(a, Sequence): - a = [a] +@overload +def broadcast_to(a: ArrayLike | float, s: int | VectorShape) -> Vector: + ... + + +@overload +def broadcast_to(a: ArrayLike | float, s: MatrixShape) -> Matrix: + ... + + +@overload +def broadcast_to(a: ArrayLike | float, s: TensorShape) -> Tensor: + ... + + +def broadcast_to(a: ArrayLike | float, s: int | Shape) -> float | Array: + """Broadcast array to a shape.""" + + _s = (s,) if not isinstance(s, Sequence) else tuple(s) s_orig = shape(a) ndim_orig = len(s_orig) - ndim_target = len(s) + ndim_target = len(_s) if ndim_orig > ndim_target: - raise ValueError("Cannot broadcast {} to {}".format(s_orig, s)) + raise ValueError(f"Cannot broadcast {s_orig} to {_s}") + + if not ndim_target: + return a # type: ignore[return-value] - s1 = list(s_orig) + s1 = [*s_orig] if ndim_orig < ndim_target: s1 = ([1] * (ndim_target - ndim_orig)) + s1 - for d1, d2 in zip(s1, s): + for d1, d2 in zip(s1, _s): if d1 != d2 and (d1 != 1 or d1 > d2): - raise ValueError("Cannot broadcast {} to {}".format(s_orig, s)) + raise ValueError(f"Cannot broadcast {s_orig} to {_s}") - m = list(_BroadcastTo(a, tuple(s1), tuple(s))) - return reshape(m, s) if len(s) > 1 else m # type: ignore[return-value] + bcast = _BroadcastTo(a, tuple(s1), tuple(_s)) + if len(_s) > 1: + result = [] # type: Array + with ArrayBuilder(result, _s) as build: + for data in bcast: + next(build).append(data) + return result + + return [*bcast] class vectorize: @@ -1708,7 +2242,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: size = len(indexes) # Cast to a list so we can update the input arguments with vectorized inputs - inputs = list(args) + inputs = [*args] # Gather all the input values we need to vectorize so we can broadcast them together vinputs = [inputs[i] for i in indexes] + [kwargs[k] for k in keys] @@ -1716,36 +2250,35 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: if vinputs: # We need to broadcast together the inputs for vectorization. # Once vectorized, use the wrapper function to replace each argument - # with the vectorized iteration. Reshape the output to match the input shape. + # with the vectorized iteration while building up the array. bcast = broadcast(*vinputs) - m = [] - for vargs in bcast: - # Update arguments with vectorized arguments - for e, i in enumerate(indexes): - inputs[i] = vargs[e] - - # Update keyword arguments with vectorized keyword argument - kwargs.update(zip(keys, vargs[size:])) - - # Call the function with vectorized inputs - m.append(self.func(*inputs, **kwargs)) - - # Reshape return to match input shape - return reshape(m, bcast.shape) if len(bcast.shape) != 1 else m + new_shape = bcast.shape + # Build up the matrix + m = [] # type: Any + with ArrayBuilder(m, new_shape) as build: + for vargs in bcast: + # Update arguments with vectorized arguments + for e, i in enumerate(indexes): + inputs[i] = vargs[e] + + # Update keyword arguments with vectorized keyword argument + kwargs.update(zip(keys, vargs[size:])) + + # Create the final dimension, writing all the data + next(build).append(self.func(*inputs, **kwargs) if kwargs else self.func(*inputs)) + return m # Nothing to vectorize, just run the function with the arguments - return self.func(*inputs, **kwargs) + return self.func(*inputs, **kwargs) if kwargs else self.func(*inputs) -class vectorize1: +class _vectorize1: """ - A special version of vectorize that only broadcasts the first two inputs. + An optimized version of vectorize that is hard coded to broadcast only the first input. - 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 - argument is always vectorized and are expected to be numbers. + This is faster than `vectorize` as it skips a lot of generalization code that allows a user + to specify specific parameters to broadcast. Additionally, users can specify `dims` allowing + us to skip analyzing the array to determine the size allowing for additional speedup. For more flexibility, use `vectorize` which allows arbitrary vectorization of any and all inputs at the cost of speed. @@ -1763,40 +2296,40 @@ def __init__(self, pyfunc: Callable[..., Any], doc: str | None = None): def __call__( self, a: ArrayLike | float, - dims: DimHints | None = None, + dims: DimHints = DN, **kwargs: Any ) -> Any: """Call the vectorized function.""" - if dims and 0 <= dims[0] <= 2: - dims_a = dims[0] - else: - dims_a = len(shape(a)) + dims_a = dims[0] if dims[0] >= 0 else len(shape(a)) + func = (lambda p1, kw=kwargs: self.func(p1, **kw)) if kwargs else self.func # type: Callable[..., Any] # Fast paths for scalar, vectors, and 2D matrices # Scalar if dims_a == 0: - return self.func(a, **kwargs) + return func(a) # Vector elif dims_a == 1: - return [self.func(i, **kwargs) for i in a] # type: ignore[union-attr] + return [func(i) 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] + return [[func(c) 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)) + m = [] # type: Any + with ArrayBuilder(m, shape(a)) as build: + for f in flatiter(a): + next(build).append(func(f)) + return m -class vectorize2: +class _vectorize2: """ - A special version of vectorize that only broadcasts the first two inputs. + An optimized version of vectorize that is hard coded to broadcast only 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. + This is faster than `vectorize` as it skips a lot of generalization code that allows a user + to specify specific parameters to broadcast. Additionally, users can specify `dims` allowing + us to skip analyzing the array to determine the size allowing for additional speedup. For more flexibility, use `vectorize` which allows arbitrary vectorization of any and all inputs at the cost of speed. @@ -1811,7 +2344,7 @@ def __init__(self, pyfunc: Callable[..., Any], doc: str | None = None): self.__name__ = self.func.__name__ self.__doc__ = self.func.__doc__ if doc is None else doc - def _vector_apply(self, a: VectorLike, b: VectorLike, **kwargs: Any) -> Vector: + def _vector_apply(self, a: VectorLike, b: VectorLike, func: Callable[..., Any]) -> Any: """Apply a function to two vectors.""" # Broadcast the vector @@ -1820,18 +2353,20 @@ def _vector_apply(self, a: VectorLike, b: VectorLike, **kwargs: Any) -> Vector: elif len(b) == 1: b = [b[0]] * len(a) - return [self.func(x, y, **kwargs) for x, y in it.zip_longest(a, b)] + return [func(x, y) for x, y in it.zip_longest(a, b)] def __call__( self, a: ArrayLike | float, b: ArrayLike | float, - dims: DimHints | None = None, + dims: DimHints = DN, **kwargs: Any ) -> Any: """Call the vectorized function.""" - if not dims or dims[0] > 2 or dims[1] > 2: + func = (lambda p1, p2, kw=kwargs: self.func(p1, p2, **kw)) if kwargs else self.func # type: Callable[..., Any] + + if dims[0] < 0 or dims[1] < 0 or dims[0] > 2 or dims[1] > 2: shape_a = shape(a) shape_b = shape(b) dims_a = len(shape_a) @@ -1839,22 +2374,37 @@ def __call__( # Handle matrices of N-D and M-D size if dims_a > 2 or dims_b > 2: + m = [] # type: Any + # Apply math to two N-D matrices if dims_a == dims_b: - # Apply math to two N-D matrices - return reshape( - [self.func(x, y, **kwargs) for x, y in zip(flatiter(a), flatiter(b))], - shape_a - ) + empty = (not shape_a or 0 in shape_a) and (not shape_b or 0 in shape_b) + if not empty and prod(shape_a) != prod(shape_b): # pragma: no cover + raise ValueError(f'Shape {shape_a} does not match the data total of {shape_b}') + with ArrayBuilder(m, shape_a) as build: + for x, y in zip(flatiter(a), flatiter(b)): + next(build).append(func(x, y)) + elif not dims_a or not dims_b: + # Apply math to a number and an N-D matrix if not dims_a: - # Apply math to a number and an N-D matrix - return reshape([self.func(a, x, **kwargs) for x in flatiter(b)], shape_b) + with ArrayBuilder(m, shape_b) as build: + for x in flatiter(b): + next(build).append(func(a, x)) + # Apply math to an N-D matrix and a number - return reshape([self.func(x, b, **kwargs) for x in flatiter(a)], shape_a) + else: + with ArrayBuilder(m, shape_a) as build: + for x in flatiter(a): + next(build).append(func(x, b)) # Apply math to an N-D matrix and an M-D matrix by broadcasting to a common shape. - bcast = broadcast(a, b) - return reshape([self.func(x, y, **kwargs) for x, y in bcast], bcast.shape) + else: + bcast = broadcast(a, b) + with ArrayBuilder(m, bcast.shape) as build: + for x, y in bcast: + next(build).append(func(x, y)) + + return m else: dims_a, dims_b = dims @@ -1862,79 +2412,293 @@ def __call__( if dims_a == dims_b: if dims_a == 1: # Apply math to two vectors - return self._vector_apply(a, b, **kwargs) # type: ignore[arg-type] + return self._vector_apply(a, b, func) # 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] + return [self._vector_apply(ra, rb, func) 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, func) 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] + self._vector_apply(ra, rb, func) for ra, rb in it.zip_longest(a, b) # type: ignore[arg-type] ] # Apply math to two scalars - return self.func(a, b, **kwargs) + return func(a, b) # Inputs containing a scalar on either side elif not dims_a or not dims_b: if dims_a == 1: # Apply math to a vector and number - return [self.func(i, b, **kwargs) for i in a] # type: ignore[union-attr] + return [func(i, b) for i in a] # type: ignore[union-attr] elif dims_b == 1: # Apply math to a number and a vector - return [self.func(a, i, **kwargs) for i in b] # type: ignore[union-attr] + return [func(a, i) for i in b] # type: ignore[union-attr] elif dims_a == 2: # Apply math to 2-D matrix and number - return [[self.func(i, b, **kwargs) for i in row] for row in a] # type: ignore[union-attr] + return [[func(i, b) for i in row] for row in a] # type: ignore[union-attr] # Apply math to a number and a matrix - return [[self.func(a, i, **kwargs) for i in row] for row in b] # type: ignore[union-attr] + return [[func(a, i) for i in row] for row in b] # type: ignore[union-attr] # Inputs are at least 2-D dimensions or below on both sides if dims_a == 1: # Apply math to vector and 2-D matrix - return [self._vector_apply(a, row, **kwargs) for row in b] # type: ignore[arg-type, union-attr] + return [self._vector_apply(a, row, func) for row in b] # type: ignore[arg-type, union-attr] # Apply math to 2-D matrix and a vector - return [self._vector_apply(row, b, **kwargs) for row in a] # type: ignore[arg-type, union-attr] + return [self._vector_apply(row, b, func) for row in a] # type: ignore[arg-type, union-attr] -@overload -def linspace(start: float, stop: float) -> Vector: - ... - +class _vectorize1_x3: + """ + A further optimized version of `_vectorize1` that limits arrays to dimensions of <= 2 and dimension to lengths of 3. -@overload -def linspace(start: VectorLike, stop: VectorLike | float) -> Matrix: - ... + Like `_vectorize1`, this limits the broadcasting to the first parameter and is faster than `vectorize` as it skips + a lot of generalization code that allows a user to specify specific parameters to broadcast. Additionally, users + can specify `dims` allowing us to skip analyzing the array to determine the size allowing for additional speedup. + Lastly, dimensions are limited to a total less than 2 and the length of dimensions is limited to 3 which allows us + to avoid looping since the dimension length is always the same. + For more flexibility, use `vectorize` which allows arbitrary vectorization of any and + all inputs at the cost of speed. + """ -@overload -def linspace(start: VectorLike | float, stop: VectorLike) -> Matrix: - ... + def __init__(self, pyfunc: Callable[..., Any], doc: str | None = None): + """Initialize.""" + self.func = pyfunc -@overload -def linspace(start: MatrixLike, stop: ArrayLike) -> Tensor: - ... + # 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 = DN, + **kwargs: Any + ) -> Any: + """Call the vectorized function.""" -@overload -def linspace(start: ArrayLike, stop: MatrixLike) -> Tensor: - ... + dims_a = dims[0] if dims[0] >= 0 else len(shape(a)) + if not (0 <= dims_a <= 2): + raise ValueError('Inputs cannot exceed 2 dimensions') -def linspace(start: ArrayLike | float, stop: ArrayLike | float, num: int = 50, endpoint: bool = True) -> Array: - """Create a series of points in a linear space.""" + func = (lambda p1, kw=kwargs: self.func(p1, **kw)) if kwargs else self.func # type: Callable[..., Any] - if num < 0: - raise ValueError('Cannot return a negative amount of values') + # Fast paths for scalar, vectors, and 2D matrices + # Scalar + if dims_a == 0: + return func(a) + # Vector + elif dims_a == 1: + return [func(a[0]), func(a[1]), func(a[2])] # type: ignore[index] + + # Column vector + if len(a[0]) == 1: # type: ignore[arg-type, index] + return [ + [func(a[0][0])], # type: ignore[index] + [func(a[1][0])], # type: ignore[index] + [func(a[2][0])] # type: ignore[index] + ] + + # 2D matrix + return [ + [func(a[0][0]), func(a[0][1]), func(a[0][2])], # type: ignore[index] + [func(a[1][0]), func(a[1][1]), func(a[1][2])], # type: ignore[index] + [func(a[2][0]), func(a[2][1]), func(a[2][2])] # type: ignore[index] + ] + + +class _vectorize2_x3: + """ + A further optimized version of `_vectorize2` that limits arrays to dimensions of <= 2 and dimension to lengths of 3. + + Like `_vectorize2`, this limits the broadcasting to the first two parameter and is faster than `vectorize` as it + skips a lot of generalization code that allows a user to specify specific parameters to broadcast. Additionally, + users can specify `dims` allowing us to skip analyzing the array to determine the size allowing for additional + speedup. Lastly, dimensions are limited to a total less than 2 and the length of dimensions is limited to 3 which + allows us to avoid looping since the dimension length is always the same. + + 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: 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: MatrixLike | VectorLike | float, + b: MatrixLike | VectorLike | float, + dims: DimHints = DN, + **kwargs: Any + ) -> Any: + """Call the vectorized function.""" + + dims_a = dims[0] if dims[0] >= 0 else len(shape(a)) + dims_b = dims[1] if dims[1] >= 0 else len(shape(b)) + + func = (lambda a, b, kw=kwargs: self.func(a, b, **kw)) if kwargs else self.func # type: Callable[..., float] + + if dims_a > 2 or dims_b > 2: + raise ValueError('Inputs cannot exceed 2 dimensions') + + # Inputs are of equal size and shape + if dims_a == dims_b: + if dims_a == 1: + # Apply math to two vectors + return [func(a[0], b[0]), func(a[1], b[1]), func(a[2], b[2])] # type: ignore[index] + elif dims_a == 2: + l1 = len(a[0]) # type: ignore[arg-type, index] + l2 = len(b[0]) # type: ignore[arg-type, index] + if l1 != l2: + if l2 == 1: + # Column vector in first position + return [ + [func(a[0][0], b[0][0]), func(a[0][1], b[0][0]), func(a[0][2], b[0][0])], # type: ignore[index] + [func(a[1][0], b[1][0]), func(a[1][1], b[1][0]), func(a[1][2], b[1][0])], # type: ignore[index] + [func(a[2][0], b[2][0]), func(a[2][1], b[2][0]), func(a[2][2], b[2][0])], # type: ignore[index] + ] + elif l1 == 1: + # Column vector in second position + return [ + [func(a[0][0], b[0][0]), func(a[0][0], b[0][1]), func(a[0][0], b[0][2])], # type: ignore[index] + [func(a[1][0], b[1][0]), func(a[1][0], b[1][1]), func(a[1][0], b[1][2])], # type: ignore[index] + [func(a[2][0], b[2][0]), func(a[2][0], b[2][1]), func(a[2][0], b[2][2])], # type: ignore[index] + ] + raise ValueError(f'Vectors of size {l1} and {l2} are not aligned') + elif l1 == 1: + # 2 column vectors + return [ + [func(a[0][0], b[0][0])], # type: ignore[index] + [func(a[1][0], b[1][0])], # type: ignore[index] + [func(a[2][0], b[2][0])], # type: ignore[index] + ] + # Apply math to two 2-D matrices + return [ + [func(a[0][0], b[0][0]), func(a[0][1], b[0][1]), func(a[0][2], b[0][2])], # type: ignore[index] + [func(a[1][0], b[1][0]), func(a[1][1], b[1][1]), func(a[1][2], b[1][2])], # type: ignore[index] + [func(a[2][0], b[2][0]), func(a[2][1], b[2][1]), func(a[2][2], b[2][2])], # type: ignore[index] + ] + # Apply math to two scalars + return func(a, b) + + # Inputs containing a scalar on either side + elif not dims_a or not dims_b: + if dims_a == 1: + # Apply math to a vector and number + return [func(a[0], b), func(a[1], b), func(a[2], b)] # type: ignore[index] + elif dims_b == 1: + # Apply math to a number and a vector + return [func(a, b[0]), func(a, b[1]), func(a, b[2])] # type: ignore[index] + elif dims_a == 2: + # Apply math to 2-D matrix and number + return [ + [func(a[0][0], b), func(a[0][1], b), func(a[0][2], b)], # type: ignore[index] + [func(a[1][0], b), func(a[1][1], b), func(a[1][2], b)], # type: ignore[index] + [func(a[2][0], b), func(a[2][1], b), func(a[2][2], b)] # type: ignore[index] + ] + # Apply math to a number and a matrix + return [ + [func(a, b[0][0]), func(a, b[0][1]), func(a, b[0][2])], # type: ignore[index] + [func(a, b[1][0]), func(a, b[1][1]), func(a, b[1][2])], # type: ignore[index] + [func(a, b[2][0]), func(a, b[2][1]), func(a, b[2][2])] # type: ignore[index] + ] + + # Inputs are at least 2-D dimensions or below on both sides + if dims_a == 1: + # Apply math to vector and 2-D matrix + return [ + [func(a[0], b[0][0]), func(a[1], b[0][1]), func(a[2], b[0][2])], # type: ignore[index] + [func(a[0], b[1][0]), func(a[1], b[1][1]), func(a[2], b[1][2])], # type: ignore[index] + [func(a[0], b[2][0]), func(a[1], b[2][1]), func(a[2], b[2][2])] # type: ignore[index] + ] + # Apply math to 2-D matrix and a vector + return [ + [func(a[0][0], b[0]), func(a[0][1], b[1]), func(a[0][2], b[2])], # type: ignore[index] + [func(a[1][0], b[0]), func(a[1][1], b[1]), func(a[1][2], b[2])], # type: ignore[index] + [func(a[2][0], b[0]), func(a[2][1], b[1]), func(a[2][2], b[2])] # type: ignore[index] + ] + + +def vectorize2( + pyfunc: Callable[..., Any], + doc: str | None = None, + params: int = 2, + only_x3: bool = False +) -> Callable[..., Any]: + """ + A more limited but faster version of `vectorize` that speed up performance at the cost of flexibility. + + 1. Broadcasted parameters are limited to the first 1 or 2 parameters via the `params` option (default 2). + 2. Further limits the expectation of the array in the first 1 or 2 parameters to dimension lengths of 3. + Additionally, the total number of dimensions cannot exceed 2. `only_x3` enables this behavior and will + provide the most speed but provides the most limited environment for operations. + + The limitations above allows the avoidance of additional generalized code that can slow the operation down. + + For more flexibility, use `vectorize` which allows arbitrary vectorization of any and + all inputs at the cost of speed. + """ + + if params == 2: + return (_vectorize2_x3 if only_x3 else _vectorize2)(pyfunc, doc) + elif params == 1: + return (_vectorize1_x3 if only_x3 else _vectorize1)(pyfunc, doc) + raise ValueError("'vectorize2' does not support dimensions greater than 2 or less than 1") + + +@deprecated("'vectorize1' is deprecated, use 'vectorize2(func, doc, params=1)' for the equivalent") +def vectorize1(pyfunc: Callable[..., Any], doc: str | None = None) -> Callable[..., Any]: # pragma: no cover + """An optimized version of vectorize that is hard coded to broadcast only the first input.""" + + return vectorize2(pyfunc, doc, params=1) + + +@overload +def linspace(start: float, stop: float, num: int = ..., endpoint: bool = ...) -> Vector: + ... + + +@overload +def linspace(start: VectorLike, stop: VectorLike | float, num: int = ..., endpoint: bool = ...) -> Matrix: + ... + + +@overload +def linspace(start: VectorLike | float, stop: VectorLike, num: int = ..., endpoint: bool = ...) -> Matrix: + ... + + +@overload +def linspace(start: MatrixLike, stop: ArrayLike, num: int = ..., endpoint: bool = ...) -> Tensor: + ... + + +@overload +def linspace(start: ArrayLike, stop: MatrixLike, num: int = ..., endpoint: bool = ...) -> Tensor: + ... + + +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: + raise ValueError('Cannot return a negative amount of values') # Return empty results over all the inputs for a request of 0 if num == 0: - return full(broadcast(start, stop).shape + (0,), []) + return full(broadcast(start, stop).shape + (0,), []) # type: ignore[return-value, arg-type] # Calculate denominator d = float(num - 1 if endpoint else num) @@ -1952,23 +2716,23 @@ def linspace(start: ArrayLike | float, stop: ArrayLike | float, num: int = 50, e if dim1 <= 1 and dim2 <= 1: # Broadcast scalars to match vectors if dim1 == 0: - start = [start] * s2[0] # type: ignore[assignment] + start = [start] * s2[0] # type: ignore[assignment, misc] s1 = s2 if dim2 == 0: - stop = [stop] * s1[0] # type: ignore[assignment] + stop = [stop] * s1[0] # type: ignore[assignment, misc] s2 = s1 # Broadcast length 1 vectors to match other vector - if s1[0] != s2[0]: - if s1[0] == 1: - start = start * s2[0] # type: ignore[operator] - elif s2[0] == 1: - stop = stop * s1[0] # type: ignore[operator] + if s1[0] != s2[0]: # type: ignore[misc] + if s1[0] == 1: # type: ignore[misc] + start = start * s2[0] # type: ignore[operator, misc] + elif s2[0] == 1: # type: ignore[misc] + stop = stop * s1[0] # type: ignore[operator, misc] else: - raise ValueError('Cannot broadcast start ({}) and stop ({})'.format(s1, s2)) + raise ValueError(f'Cannot broadcast start ({s1}) and stop ({s2})') # Apply linear interpolation steps across the vectors - values = list(zip(start, stop)) # type: ignore[arg-type] + values = [*zip(start, stop)] # type: ignore[arg-type] m1 = [] # type: Matrix for r in range(num): m1.append([]) @@ -1977,264 +2741,529 @@ def linspace(start: ArrayLike | float, stop: ArrayLike | float, num: int = 50, e return m1 # To apply over N x M inputs, apply the steps over the broadcasted results (slower) - m = [] + m = [] # type: Tensor bcast = broadcast(start, stop) - for r in range(num): - bcast.reset() - for a, b in bcast: - m.append(lerp(a, b, r / d if d != 0 else 0.0)) - - # Reshape to the expected shape - return reshape(m, (num,) + bcast.shape) # type: ignore[return-value] + new_shape = (num,) + bcast.shape + with ArrayBuilder(m, new_shape) as build: + for r in range(num): + bcast.reset() + for a, b in bcast: + next(build).append(lerp(a, b, r / d if d != 0 else 0.0)) + return m 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) if kwargs else math.isclose(a, b) 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: DimHints | None = ..., **kwargs: Any) -> bool: +def isclose(a: float, b: float, *, dims: DimHints = ..., **kwargs: Any) -> bool: ... @overload -def isclose(a: VectorLike, b: VectorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> VectorBool: +def isclose(a: VectorLike, b: VectorLike, *, dims: DimHints = ..., **kwargs: Any) -> VectorBool: ... @overload -def isclose(a: MatrixLike, b: MatrixLike, *, dims: DimHints | None = ..., **kwargs: Any) -> MatrixBool: +def isclose(a: MatrixLike, b: MatrixLike, *, dims: DimHints = ..., **kwargs: Any) -> MatrixBool: ... @overload -def isclose(a: TensorLike, b: TensorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> TensorBool: +def isclose(a: TensorLike, b: TensorLike, *, dims: DimHints = ..., **kwargs: Any) -> TensorBool: ... -isclose = vectorize2(_isclose) # type: ignore[assignment] +isclose = vectorize2(_isclose, doc="Test if a value or value(s) in an array are close to another value(s).") @overload # type: ignore[no-overload-impl] -def isnan(a: float, *, dims: DimHints | None = ..., **kwargs: Any) -> bool: +def isnan(a: float, *, dims: DimHints = ..., **kwargs: Any) -> bool: ... @overload -def isnan(a: VectorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> VectorBool: +def isnan(a: VectorLike, *, dims: DimHints = ..., **kwargs: Any) -> VectorBool: ... @overload -def isnan(a: MatrixLike, *, dims: DimHints | None = ..., **kwargs: Any) -> MatrixBool: +def isnan(a: MatrixLike, *, dims: DimHints = ..., **kwargs: Any) -> MatrixBool: ... @overload -def isnan(a: TensorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> TensorBool: +def isnan(a: TensorLike, *, dims: DimHints = ..., **kwargs: Any) -> TensorBool: ... -isnan = vectorize1(math.isnan) # type: ignore[assignment] +isnan = vectorize2(math.isnan, doc="Test if a value or values in an array are NaN.", params=1) -def allclose(a: MathType, b: MathType, **kwargs: Any) -> bool: +def allclose(a: ArrayType, b: ArrayType, **kwargs: Any) -> bool: """Test if all are close.""" - return all(isclose(a, b, **kwargs)) + return all(isclose(a, b, **kwargs) if kwargs else isclose(a, b)) + + +@overload # type: ignore[no-overload-impl] +def multiply(a: float, b: float, *, dims: DimHints = ...) -> float: + ... + + +@overload +def multiply(a: float | VectorLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def multiply(a: VectorLike, b: float | VectorLike, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def multiply(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +@overload +def multiply(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +@overload +def multiply(a: TensorLike, b: float | ArrayLike, *, dims: DimHints = ...) -> Tensor: + ... + + +@overload +def multiply(a: float | ArrayLike, b: TensorLike, *, dims: DimHints = ...) -> Tensor: + ... + + +multiply = vectorize2(operator.mul, doc="Multiply two arrays or floats.") @overload # type: ignore[no-overload-impl] -def multiply(a: float, b: float, *, dims: DimHints | None = ...) -> float: +def divide(a: float, b: float, *, dims: DimHints = ...) -> float: ... @overload -def multiply(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: +def divide(a: float | VectorLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def multiply(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: +def divide(a: VectorLike, b: float | VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def multiply(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def divide(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints = ...) -> Matrix: ... @overload -def multiply(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def divide(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: ... @overload -def multiply(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: +def divide(a: TensorLike, b: float | ArrayLike, *, dims: DimHints = ...) -> Tensor: ... @overload -def multiply(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: +def divide(a: float | ArrayLike, b: TensorLike, *, dims: DimHints = ...) -> Tensor: ... -multiply = vectorize2(operator.mul, doc="Multiply two arrays or floats.") # type: ignore[assignment] +divide = vectorize2(operator.truediv, doc="Divide two arrays or floats.") @overload # type: ignore[no-overload-impl] -def divide(a: float, b: float, *, dims: DimHints | None = ...) -> float: +def add(a: float, b: float, *, dims: DimHints = ...) -> float: ... @overload -def divide(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: +def add(a: float | VectorLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def divide(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: +def add(a: VectorLike, b: float | VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def divide(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def add(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints = ...) -> Matrix: ... @overload -def divide(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def add(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: ... @overload -def divide(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: +def add(a: TensorLike, b: float | ArrayLike, *, dims: DimHints = ...) -> Tensor: ... @overload -def divide(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: +def add(a: float | ArrayLike, b: TensorLike, *, dims: DimHints = ...) -> Tensor: ... -divide = vectorize2(operator.truediv, doc="Divide two arrays or floats.") # type: ignore[assignment] +add = vectorize2(operator.add, doc="Add two arrays or floats.") @overload # type: ignore[no-overload-impl] -def add(a: float, b: float, *, dims: DimHints | None = ...) -> float: +def subtract(a: float, b: float, *, dims: DimHints = ...) -> float: + ... + + +@overload +def subtract(a: float | VectorLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def subtract(a: VectorLike, b: float | VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def add(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: +def subtract(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints = ...) -> Matrix: ... @overload -def add(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: +def subtract(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: ... @overload -def add(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def subtract(a: TensorLike, b: float | ArrayLike, *, dims: DimHints = ...) -> Tensor: ... @overload -def add(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def subtract(a: float | ArrayLike, b: TensorLike, *, dims: DimHints = ...) -> Tensor: + ... + +subtract = vectorize2(operator.sub, doc="Subtract two arrays or floats.") + + +@overload # type: ignore[no-overload-impl] +def multiply_x3(a: float, b: float, *, dims: DimHints = ...) -> float: ... @overload -def add(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: +def multiply_x3(a: float | VectorLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def add(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: +def multiply_x3(a: VectorLike, b: float | VectorLike, *, dims: DimHints = ...) -> Vector: ... -add = vectorize2(operator.add, doc="Add two arrays or floats.") # type: ignore[assignment] +@overload +def multiply_x3(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +@overload +def multiply_x3(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +multiply_x3 = vectorize2( + operator.mul, + doc="Multiply two arrays or floats.\n\nOptimized for scalars, dimensions <= 2, and vectors of lengths of 3.", + only_x3=True +) @overload # type: ignore[no-overload-impl] -def subtract(a: float, b: float, *, dims: DimHints | None = None) -> float: +def divide_x3(a: float, b: float, *, dims: DimHints = ...) -> float: ... @overload -def subtract(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: +def divide_x3(a: float | VectorLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def subtract(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: +def divide_x3(a: VectorLike, b: float | VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def subtract(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def divide_x3(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints = ...) -> Matrix: ... @overload -def subtract(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: +def divide_x3(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +divide_x3 = vectorize2( + operator.truediv, + doc="Divide two arrays or floats.\n\nOptimized for scalars, dimensions <= 2, and vectors of lengths of 3.", + only_x3=True +) + + +@overload # type: ignore[no-overload-impl] +def add_x3(a: float, b: float, *, dims: DimHints = ...) -> float: ... @overload -def subtract(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: +def add_x3(a: float | VectorLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: ... @overload -def subtract(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: +def add_x3(a: VectorLike, b: float | VectorLike, *, dims: DimHints = ...) -> Vector: ... -subtract = vectorize2(operator.sub, doc="Subtract two arrays or floats.") # type: ignore[assignment] +@overload +def add_x3(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... -def full(array_shape: int | ShapeLike, fill_value: float | ArrayLike) -> Array: + +@overload +def add_x3(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +add_x3 = vectorize2( + operator.add, + doc="Add two arrays or floats.\n\nOptimized for scalars, dimensions <= 2, and vectors of lengths of 3.", + only_x3=True +) + + +@overload # type: ignore[no-overload-impl] +def subtract_x3(a: float, b: float, *, dims: DimHints = ...) -> float: + ... + + +@overload +def subtract_x3(a: float | VectorLike, b: VectorLike, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def subtract_x3(a: VectorLike, b: float | VectorLike, *, dims: DimHints = ...) -> Vector: + ... + + +@overload +def subtract_x3(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +@overload +def subtract_x3(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints = ...) -> Matrix: + ... + + +subtract_x3 = vectorize2( + operator.sub, + doc="Subtract two arrays or floats.\n\nOptimized for scalars, dimensions <= 2, and vectors of lengths of 3.", + only_x3=True +) + + +@overload +def full(array_shape: EmptyShape, fill_value: float | ArrayLike) -> float: + ... + +@overload +def full(array_shape: int | VectorShape, fill_value: float | ArrayLike) -> Vector: + ... + + +@overload +def full(array_shape: MatrixShape, fill_value: float | ArrayLike) -> Matrix: + ... + + +@overload +def full(array_shape: TensorShape, fill_value: float | ArrayLike) -> Tensor: + ... + + +def full(array_shape: int | Shape, fill_value: float | ArrayLike) -> Array | float: """Create and fill a shape with the given values.""" # Ensure `shape` is a sequence of sizes - array_shape = (array_shape,) if not isinstance(array_shape, Sequence) else tuple(array_shape) + s = (array_shape,) if not isinstance(array_shape, Sequence) else tuple(array_shape) + + # Handle scalar target + if not s: + if not isinstance(fill_value, Sequence): + return fill_value + _s = shape(fill_value) + if prod(_s) == 1: + return ravel(fill_value)[0] # Normalize `fill_value` to be an array. - if not isinstance(fill_value, Sequence): - return reshape([fill_value] * prod(array_shape), array_shape) # type: ignore[return-value] + elif not isinstance(fill_value, Sequence): + m = [] # type: Array + with ArrayBuilder(m, s) as build: + for v in [fill_value] * prod(s): + next(build).append(v) + return m # If the shape doesn't fit the data, try and broadcast it. # If it does fit, just reshape it. - if shape(fill_value) != tuple(array_shape): - return broadcast_to(fill_value, array_shape) - return reshape(fill_value, array_shape) # type: ignore[return-value] + if shape(fill_value) != s: + return broadcast_to(fill_value, s) # type: ignore[arg-type] + return acopy(fill_value) + + +@overload +def ones(array_shape: EmptyShape) -> float: + ... + + +@overload +def ones(array_shape: int | VectorShape) -> Vector: + ... + + +@overload +def ones(array_shape: MatrixShape) -> Matrix: + ... + + +@overload +def ones(array_shape: TensorShape) -> Tensor: + ... -def ones(array_shape: int | ShapeLike) -> Array: +def ones(array_shape: int | Shape) -> Array | float: """Create and fill a shape with ones.""" - return full(array_shape, 1.0) + return full(array_shape, 1.0) # type: ignore[arg-type] + +@overload +def zeros(array_shape: EmptyShape) -> float: + ... + +@overload +def zeros(array_shape: int | VectorShape) -> Vector: + ... + + +@overload +def zeros(array_shape: MatrixShape) -> Matrix: + ... -def zeros(array_shape: int | ShapeLike) -> Array: + +@overload +def zeros(array_shape: TensorShape) -> Tensor: + ... + + +def zeros(array_shape: int | Shape) -> Array | float: """Create and fill a shape with zeros.""" - return full(array_shape, 0.0) + return full(array_shape, 0.0) # type: ignore[arg-type] -def ndindex(*s: ShapeLike) -> Iterator[tuple[int, ...]]: +def ndindex(*s: Shape) -> Iterator[tuple[int, ...]]: """Iterate dimensions.""" yield from it.product( - *(range(d) for d in (s[0] if not isinstance(s[0], int) and len(s) == 1 else s)) # type: ignore[call-overload] + *(range(d) for d in (s[0] if not isinstance(s[0], int) and len(s) == 1 else s)) # type: ignore[arg-type] ) +def ndenumerate(a: ArrayLike | float) -> Iterator[tuple[Shape, Any]]: + """Iterate dimensions.""" + + for idx in ndindex(shape(a)): + t = a # type: Any + for i in idx: + t = t[i] + yield idx, t + + +class ArrayBuilder: + """Auto drain an iterator.""" + + def __init__(self, a: Array, s: Shape) -> None: + """Initialize.""" + + self.i = self._new_array_builder(a, s) + + def __enter__(self) -> Iterator[Any]: + """Enter.""" + + return self.i + + def __exit__(self: Any, exc_type: Any, exc_value: Any, traceback: Any) -> None: + """Drain the iterator.""" + + for _ in self.i: # pragma: no cover + pass + + @staticmethod + def _new_array_builder(a: Array, s: Shape) -> Iterator[Any]: + """Generate a new array based on the specified size returning each row for appending.""" + + dims = len(s) + empty = not s or s[-1] == 0 + for idx in ndindex(s if not empty else (s[:-1] + (1,))): + t = a # type: Any + for d in range(dims - 1): + if not t: + for _ in range(s[d]): + t.append([]) # noqa: PERF401 + t = t[idx[d]] + if not empty: + yield t + + +class MultiArrayBuilder(ArrayBuilder): + """Auto drain an iterator.""" + + def __init__(self, a: Sequence[Array], s: Sequence[Shape]) -> None: + """Initialize.""" + + self.mi = [self._new_array_builder(_a, _s) for _a, _s in it.zip_longest(a, s)] + + def __enter__(self) -> list[Iterator[Any]]: # type: ignore[override] + """Enter.""" + + return self.mi + + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + """Drain the iterator.""" + + for i in self.mi: + for _ in i: # pragma: no cover + pass + + def flatiter(array: float | ArrayLike) -> Iterator[float]: """Traverse an array returning values.""" @@ -2248,7 +3277,7 @@ def flatiter(array: float | ArrayLike) -> Iterator[float]: def ravel(array: float | ArrayLike) -> Vector: """Return a flattened vector.""" - return list(flatiter(array)) + return [*flatiter(array)] def _frange(start: float, stop: float, step: float) -> Iterator[float]: @@ -2279,9 +3308,10 @@ def arange( start = 0 if isinstance(start, int) and isinstance(stop, int) and isinstance(step, int): - return list(range(start, stop, step)) + value = [*range(start, stop, step)] # type: ignore[arg-type] else: - return list(_frange(float(start), float(stop), float(step))) + value = [*_frange(float(start), float(stop), float(step))] # type: ignore[arg-type] + return value @overload @@ -2304,7 +3334,7 @@ def transpose(array: TensorLike) -> Tensor: ... -def transpose(array: ArrayLike | float) -> Array | float: +def transpose(array: ArrayLike | float) -> float | Array: """ A simple transpose of a matrix. @@ -2312,10 +3342,20 @@ def transpose(array: ArrayLike | float) -> Array | float: we don't have a need for that, nor the desire to figure it out :). """ - s = shape(array)[::-1] - if not s: - return array # type: ignore[return-value] + s = shape(array)[::-1] # type: Shape + l = len(s) + # Number + if l == 0: + return array # type: ignore[return-value] + # Vector + if l == 1: + return [*array] # type: ignore[misc] + # 2 x 2 matrix + if l == 2: + return [[*z] for z in zip(*array)] # type: ignore[misc] + + # N x M matrix if s and s[0] == 0: s = s[1:] + (0,) total = prod(s[:-1]) @@ -2323,11 +3363,11 @@ def transpose(array: ArrayLike | float) -> Array | float: total = prod(s) # Create the array - m = [] # type: Any + m = [] # type: Array # Calculate data sizes dims = len(s) - length = s[-1] + length = s[-1] # type: ignore[misc] # Initialize indexes so we can properly write our data idx = [0] * dims @@ -2364,10 +3404,30 @@ def transpose(array: ArrayLike | float) -> Array | float: idx[x] += 1 break - return m # type: ignore[no-any-return] + return m + + +@overload +def reshape(array: ArrayLike | float, new_shape: EmptyShape) -> float: + ... + + +@overload +def reshape(array: ArrayLike | float, new_shape: int | VectorShape) -> Vector: + ... -def reshape(array: ArrayLike | float, new_shape: int | ShapeLike) -> float | Array: +@overload +def reshape(array: ArrayLike | float, new_shape: MatrixShape) -> Matrix: + ... + + +@overload +def reshape(array: ArrayLike | float, new_shape: TensorShape) -> Tensor: + ... + + +def reshape(array: ArrayLike | float, new_shape: int | Shape) -> float | Array: """Change the shape of an array.""" # Ensure floats are arrays @@ -2376,7 +3436,7 @@ def reshape(array: ArrayLike | float, new_shape: int | ShapeLike) -> float | Arr # Normalize shape specifier to a sequence if not isinstance(new_shape, Sequence): - new_shape = [new_shape] + new_shape = (new_shape,) # Shape to a scalar if not new_shape: @@ -2384,7 +3444,7 @@ def reshape(array: ArrayLike | float, new_shape: int | ShapeLike) -> float | Arr if len(v) == 1: return v[0] # 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))) + raise ValueError(f'Shape {new_shape} does not match the data total of {shape(array)}') current_shape = shape(array) @@ -2397,45 +3457,82 @@ def reshape(array: ArrayLike | float, new_shape: int | ShapeLike) -> float | Arr # Make sure we can actually reshape. total = prod(new_shape) if not empty else prod(new_shape[:-1]) if not empty and total != prod(current_shape): - raise ValueError('Shape {} does not match the data total of {}'.format(new_shape, shape(array))) + raise ValueError(f'Shape {new_shape} does not match the data total of {shape(array)}') # Create the array - m = [] # type: Any + m = [] # type: Array + with ArrayBuilder(m, new_shape) as build: + # Create an iterator to traverse the data + for data in flatiter(array) if len(current_shape) > 1 else iter(array): # type: ignore[arg-type] + next(build).append(data) - # Calculate data sizes - dims = len(new_shape) + return m - # Create an iterator to traverse the data - data = flatiter(array) if len(current_shape) > 1 else iter(array) # type: ignore[arg-type] - # Build the new array - for idx in ndindex(new_shape[:-1] if new_shape and not new_shape[-1] else new_shape): - # Navigate to the proper index to start writing data. - # If the dimension hasn't been created yet, create it. - t = m # type: Any - for d in range(dims - 1): - if not t: - for _ in range(new_shape[d]): - t.append([]) # noqa: PERF401 - t = t[idx[d]] +@overload +def _quick_shape(a: float) -> EmptyShape: + ... - # Create the final dimension, writing all the data - if not empty: - t.append(next(data)) - return m # type: ignore[no-any-return] +@overload +def _quick_shape(a: VectorLike) -> VectorShape: + ... + +@overload +def _quick_shape(a: MatrixLike) -> MatrixShape: + ... -def _shape(a: ArrayLike | float, s: Shape) -> Shape: + +@overload +def _quick_shape(a: TensorLike) -> TensorShape: + ... + + +def _quick_shape(a: ArrayLike | float) -> Shape: """ - Get the shape of the array. + Acquire shape taking shortcuts by assuming a non-ragged, consistently shaped array. - We only test the first index at each depth for speed. + No checking for consistency is performed allowing for a quicker check. """ + t = a # type: Any + s = [] + while isinstance(t, Sequence): + l = len(t) + s.append(l) + if not l: + break + t = t[0] + return tuple(s) + + +@overload +def shape(a: float) -> EmptyShape: + ... + + +@overload +def shape(a: VectorLike) -> VectorShape: + ... + + +@overload +def shape(a: MatrixLike) -> MatrixShape: + ... + + +@overload +def shape(a: TensorLike) -> TensorShape: + ... + + +def shape(a: ArrayLike | float) -> Shape: + """Get the shape of a list.""" + # Found a scalar input if not isinstance(a, Sequence): - return s + return () # Get the length size = len(a) @@ -2445,21 +3542,15 @@ def _shape(a: ArrayLike | float, s: Shape) -> Shape: return (size,) # Recursively get the shape of the first entry and compare against the others - first = _shape(a[0], s) + first = shape(a[0]) for r in range(1, size): - if _shape(a[r], s) != first: + if shape(a[r]) != first: raise ValueError('Ragged lists are not supported') # Construct the final shape return (size,) + first -def shape(a: ArrayLike | float) -> Shape: - """Get the shape of a list.""" - - return _shape(a, ()) - - def fill_diagonal(matrix: Matrix | Tensor, val: float | ArrayLike, wrap: bool = False) -> None: """Fill an N-D matrix diagonal.""" @@ -2527,16 +3618,16 @@ def identity(size: int) -> Matrix: @overload -def diag(array: VectorLike, k: int = 0) -> Matrix: +def diag(array: VectorLike, k: int = ...) -> Matrix: ... @overload -def diag(array: MatrixLike, k: int = 0) -> Vector: +def diag(array: MatrixLike, k: int = ...) -> Vector: ... -def diag(array: VectorLike | MatrixLike, k: int = 0) -> Array: +def diag(array: VectorLike | MatrixLike, k: int = 0) -> Vector | Matrix: """Create a diagonal matrix from a vector or return a vector of the diagonal of a matrix.""" s = shape(array) @@ -2563,7 +3654,7 @@ def diag(array: VectorLike | MatrixLike, k: int = 0) -> Array: return m else: # Extract the requested diagonal from a rectangular 2-D matrix - size = s[1] + size = s[1] # type: ignore[misc] d = [] for i, r in enumerate(array): pos = i + k @@ -2603,25 +3694,37 @@ def lu( if dims < 2: raise ValueError('LU decomposition requires an array larger than a vector') elif dims > 2: - last = s[-2:] - first = s[:-2] - rows = list(_extract_rows(matrix, s)) + last = s[-2:] # type: tuple[int, int] # type: ignore[assignment] + first = s[:-2] # type: Shape + rows = [*_extract_rows(matrix, s)] step = last[-2] - results = [] - zipped = zip( - *( - lu(rows[r:r + step], permute_l=permute_l, p_indices=p_indices, _shape=last) - for r in range(0, len(rows), step) - ) - ) - for parts in zipped: - results.append(reshape(parts, first + shape(parts[0]))) # noqa: PERF401 - return tuple(results) + l = [] # type: Any + u = [] # type: Any + if not permute_l: + p = [] # type: Any + builder = MultiArrayBuilder([p, l, u], [first, first, first]) + else: + builder = MultiArrayBuilder([l, u], [first, first]) + + with builder as arrays: + for r in range(0, len(rows), step): + result = lu(rows[r:r + step], permute_l=permute_l, p_indices=p_indices, _shape=last) + if not permute_l: + next(arrays[0]).append(result[0]) + next(arrays[1]).append(result[1]) + next(arrays[2]).append(result[2]) + else: + next(arrays[0]).append(result[0]) + next(arrays[1]).append(result[1]) + if permute_l: + return l, u + return p, l, u # Wide or tall matrices wide = tall = False diff = s[0] - s[1] - if diff: + empty = diff == s[0] + if not empty and diff: matrix = acopy(matrix) # Wide @@ -2630,7 +3733,7 @@ def lu( size = s[1] wide = True for _ in range(diff): - matrix.append([0.0] * size) # type: ignore[list-item] # noqa: PERF401 + matrix.append([0.0] * size) # type: ignore[arg-type] # noqa: PERF401 # Tall else: tall = True @@ -2638,22 +3741,28 @@ def lu( row.extend([0.0] * diff) # type: ignore[list-item] # Initialize the triangle matrices along with the permutation matrix. - if p_indices or permute_l: - p = list(range(size)) # type: Any - l = identity(size) + if empty: + p = [] + l = acopy(matrix) + u = [] + size = 0 else: - p = identity(size) - l = [list(row) for row in p] - u = [list(row) for row in matrix] + if p_indices or permute_l: + p = [*range(size)] + l = identity(size) + else: + p = identity(size) + l = [[*row] for row in p] + u = [[*row] for row in matrix] # Create upper and lower triangle in 'u' and 'l'. 'p' tracks the permutation (relative position of rows) for i in range(size - 1): # Partial pivoting: identify the row with the maximal value in the column j = i - maximum = abs(u[i][i]) # type: ignore[var-annotated, arg-type] + maximum = abs(u[i][i]) for k in range(i + 1, size): - a = abs(u[k][i]) # type: ignore[var-annotated, arg-type] + a = abs(u[k][i]) if a > maximum: j = k maximum = a @@ -2677,9 +3786,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] # type: ignore[operator] + scalar = u[j][i] / u[i][i] for k in range(i, size): - u[j][k] += -u[i][k] * scalar # type: ignore[operator] + u[j][k] += -u[i][k] * scalar l[j][k] += l[i][k] * scalar # Clean up the wide and tall matrices @@ -2714,7 +3823,7 @@ def _forward_sub_vector(a: Matrix, b: Vector, size: int) -> Vector: return b -def _forward_sub_matrix(a: Matrix, b: Matrix, s: Shape) -> Matrix: +def _forward_sub_matrix(a: Matrix, b: Matrix, s: ArrayShape) -> Matrix: """Forward substitution for solution of `L x = b` where `b` is a matrix.""" size1, size2 = s @@ -2728,29 +3837,600 @@ def _forward_sub_matrix(a: Matrix, b: Matrix, s: Shape) -> Matrix: return b -def _back_sub_vector(a: Matrix, b: Vector, size: int) -> Vector: - """Back substitution for solution of `U x = b`.""" +def _back_sub_vector(a: Matrix, b: Vector, size: int) -> Vector: + """Back substitution for solution of `U x = b`.""" + + for i in range(size - 1, -1, -1): + v = b[i] + for j in range(i + 1, size): + v -= a[i][j] * b[j] + b[i] = v / a[i][i] + return b + + +def _back_sub_matrix(a: Matrix, b: Matrix, s: ArrayShape) -> Matrix: + """Back substitution for solution of `U x = b`.""" + + size1, size2 = s + for i in range(size1 - 1, -1, -1): + v = b[i] # type: Any + for j in range(i + 1, size1): + for k in range(size2): + v[k] -= a[i][j] * b[j][k] + for j in range(size2): + b[i][j] /= a[i][i] + return b + + +def _householder_reduction_bidiagonal( + m: int, + n: int, + e: Vector, + u: Matrix, + q: Vector, + tol: float +) -> tuple[float, int, float, float]: + """Householder's reduction to bidiagonal form.""" + + g = x = y = 0.0 + l = 0 + + for i in range(n): + e[i] = g + s = 0.0 + l = i + 1 + + for j in range(i, m): + s += u[j][i] ** 2 + + if s < tol: + g = 0.0 + + else: + f = u[i][i] + g = math.sqrt(s) + if f >= 0.0: + g = -g + h = f * g - s + u[i][i] = f - g + + for j in range(l, n): + s = 0.0 + + for k in range(i,m): + s += u[k][i] * u[k][j] + + f = s / h + + for k in range(i, m): + u[k][j] += f * u[k][i] + + q[i] = g + s = 0.0 + + for j in range(l,n): + s += u[i][j] ** 2 + + if s < tol: + g = 0.0 + + else: + f = u[i][i + 1] + + g = math.sqrt(s) + if f >= 0.0: + g = -g + + h = f * g - s + u[i][i + 1] = f - g + + for j in range(l, n): + e[j] = u[i][j] / h + + for j in range(l, m): + s = 0.0 + for k in range(l, n): + s += u[j][k] * u[i][k] + + for k in range(l, n): + u[j][k] += s * e[k] + + y = abs(q[i]) + abs(e[i]) + + if y > x: + x = y + + return g, l, x, y + + +def _accumulate_right_transfrom(n: int, g: float, l: int, e: Vector, u: Matrix, v: Matrix) -> float: + """Accumulation of right hand transformations.""" + + for i in range(n - 1, -1, -1): + if g != 0.0: + h = g * u[i][i + 1] + + for j in range(l, n): + v[j][i] = u[i][j] / h + + for j in range(l, n): + s = 0.0 + + for k in range(l , n): + s += u[i][k] * v[k][j] + + for k in range(l, n): + v[k][j] += s * v[k][i] + + for j in range(l, n): + v[i][j] = 0.0 + v[j][i] = 0.0 + + v[i][i] = 1.0 + g = e[i] + l = i + + return g + + +def _accumulate_left_transform(m: int, n: int, g: float, l: int, u: Matrix, q: Vector) -> float: + """Accumulation of left hand transformations.""" + + for i in range(n - 1, -1, -1): + l = i + 1 + g = q[i] + + for j in range(l, n): + u[i][j] = 0.0 + + if g != 0.0: + h = u[i][i] * g + + for j in range(l, n): + s = 0.0 + + for k in range(l, m): + s += u[k][i] * u[k][j] + + f = s / h + for k in range(i, m): + u[k][j] += f * u[k][i] + + for j in range(i, m): + u[j][i] = u[j][i] / g + + else: + for j in range(i, m): + u[j][i] = 0.0 + + u[i][i] += 1.0 + + return g + + +def _compute_orthogonal_rotation(a: float, b: float) -> tuple[float, float, float]: + """Compute orthogonal rotation avoiding divide by zero.""" + + d = math.sqrt(a ** 2 + b ** 2) + if d != 0: + return a / d, b / d, d + return 0.0, 1.0, 0.0 + + +def _diagonalization_of_bidiagonal( + m: int, + n: int, + g: float, + x: float, + y: float, + e: Vector, + u: Matrix, + q: Vector, + v: Matrix, + eps: float +) -> None: + """Diagonalization of the bidiagonal form.""" + + l = 0 + eps = eps * x + for k in range(n - 1, -1, -1): + maxiter = 50 + while maxiter: + + # Test f splitting + cancel = False + for l in range(k, -1, -1): + if abs(e[l]) <= eps: + break + + if abs(q[l-1]) <= eps: + cancel = True + break + + if cancel: + # Cancellation of e[l] if l>0 + c = 0.0 + s = 1.0 + l1 = l - 1 + + for i in range(l, k + 1): + f = s * e[i] + e[i] = c * e[i] + + if abs(f) <= eps: # pragma: no cover + break + + g = q[i] + c, s, h = _compute_orthogonal_rotation(g, -f) + q[i] = h + for j in range(m): + y = u[j][l1] + z = u[j][i] + u[j][l1] = y * c + z * s + u[j][i] = -y * s + z * c + + # Test f convergence + z = q[k] + if l == k: + # Convergence + if z < 0.0: + # q[k] is made non-negative + q[k] = -z + for j in range(n): + v[j][k] = -v[j][k] + break + + # Shift from bottom 2x2 minor + # TODO: Is it possible that h, y, or x will be zero here? + # If so, the two f calculations could cause a divide by zero. + # If we can find a case, we can decide how to move forward. + x = q[l] + y = q[k - 1] + g = e[k - 1] + h = e[k] + f = ((y - z) * (y + z) + (g - h) * (g + h)) / (2.0 * h * y) + g = math.hypot(f, 1.0) + fg = f - g if f < 0 else f + g + f = ((x - z) * (x + z) + h * (y / fg - h)) / x + + # Next QR transformation + c = s = 1.0 + for i in range(l + 1, k + 1): + g = e[i] + y = q[i] + h = s * g + g = c * g + c, s, z = _compute_orthogonal_rotation(f, h) + e[i - 1] = z + f = x * c + g * s + g = -x * s + g * c + h = y * s + y = y * c + + for j in range(n): + x = v[j][i - 1] + z = v[j][i] + v[j][i - 1] = x * c + z * s + v[j][i] = -x * s + z * c + + c, s, z = _compute_orthogonal_rotation(f, h) + q[i-1] = z + f = c * g + s * y + x = -s * g + c * y + + for j in range(m): + y = u[j][i - 1] + z = u[j][i] + u[j][i-1] = y * c + z * s + u[j][i] = -y * s + z * c + + e[l] = 0.0 + e[k] = f + q[k] = x + + maxiter -= 1 + else: # pragma: no cover + raise ValueError('Could not converge on an SVD solution') + + +def _svd(a: MatrixLike, m: int, n: int, full_matrices: bool = True, compute_uv: bool = True) -> Any: + """ + Compute the singular value decomposition of a matrix. + + Handbook Series Linear Algebra + Singular Value Decomposition and Least Squares Solutions + G. H. Golub and C. Reinsch + https://people.duke.edu/~hpgavin/SystemID/References/Golub+Reinsch-NM-1970.pdf + + Some small changes were made to support wide and tall matrices. Additionally, + we fixed some cases where divide by zero could occur and confirmed that the + solutions still yielded `A = U∑V^T`. + """ + + eps = EPS + tol = MIN_FLOAT / EPS + + u = acopy(a) + square = m == n + wide = not square and m < n + diff = 0 + + if wide: + u = transpose(u) + m, n = n, m + + if full_matrices and not square: + diff = m - n + for r in u: + r.extend([0.0] * diff) + n = m + + e = [0.0] * n + q = [0.0] * n + v = zeros((n, n)) + + g, l, x, y = _householder_reduction_bidiagonal(m, n, e, u, q, tol) + if compute_uv: + g = _accumulate_right_transfrom(n, g, l, e, u, v) + g = _accumulate_left_transform(m, n, g, l, u, q) + _diagonalization_of_bidiagonal(m, n, g, x, y, e, u, q, v, eps) + + if full_matrices and not square: + if compute_uv: + del v[-diff:] + for r in v: + del r[-diff:] + del q[-diff:] + + if compute_uv: + if wide: + v, u = u, v + + if compute_uv: + return u, q, v + return q + + +def svd( + a: MatrixLike | TensorLike, + full_matrices: bool = True, + compute_uv: bool = True +) -> Any: + """ + Compute the singular value decomposition of a matrix. + + This differs from Numpy in that it returns `U, S, V` instead of `U, S, V^T`. + + There are far more efficient and modern algorithms than what we have implemented here. + This approach is not recommended for very large matrices as it will be too slow, While + it is sufficient for computing smaller matrices, it is not practical for very large + matrices, such as compressing images with thousands of pixels. If you are doing serious + computations with very large matrices, Numpy or SciPy should be strongly considered. + """ + + s = shape(a) + dims = len(s) + + # Ensure we have at least a matrix + if dims < 2: + raise ValueError('Array must be at least 2 dimensional') + + # Handle stacked matrix cases + elif dims > 2: + last = s[-2:] # type: tuple[int, int] # type: ignore[misc] + first = s[:-2] # type: Shape # type: ignore[misc] + rows = [*_extract_rows(a, s)] + step = last[-2] + m, n = last + sigma = [] # type: Any + if compute_uv: + u = [] # type: Any + v = [] # type: Any + builder = MultiArrayBuilder([u, sigma, v], [first, first, first]) + else: + builder = MultiArrayBuilder([sigma], [first]) + with builder as arrays: + for r in range(0, len(rows), step): + result = _svd(rows[r:r + step], m, n, full_matrices, compute_uv) + if compute_uv: + next(arrays[0]).append(result[0]) + next(arrays[1]).append(result[1]) + next(arrays[2]).append(result[2]) + else: + next(arrays[0]).append(result) + if compute_uv: + return u, sigma, v + return sigma + + return _svd(a, s[0], s[1], full_matrices, compute_uv) # type: ignore[arg-type] + + +def svdvals(a: MatrixLike | TensorLike) -> Any: + """Get the s values from SVD.""" + + return svd(a, False, False) + + +def _qr(a: Matrix, m: int, n: int, mode: str = 'reduced') -> Any: + """Perform QR decomposition on a matrix.""" + + # Setup configuration flags + mode_raw = mode_r = mode_complete = False + if mode == 'r': + mode_r = True + mode_raw = mode_complete = False + elif mode == 'complete': + mode_complete = True + mode_r = mode_raw = False + elif mode == 'raw': + mode_raw = mode_r = True + mode_complete = False + + # Initialize Q and R and make adjustments for wide or tall matrices + r = acopy(a) + square = m == n + empty = not n + wide = not square and m < n + tall = not wide and not square + diff = 0 + if wide: + diff = n - m + for _ in range(diff): + r.append([0.0] * n) + elif tall: + diff = m - n + + q = identity(m) + + # Initialize containers for householder reflections and tau values if raw mode + if mode_raw: + h = [] # type: Any + tau = [0.0] * (m if not tall else n) + + for k in range(0, m - 1 if not tall else n): + # Calculate the householder reflections + norm = math.sqrt(sum([r[i][k] ** 2 for i in range(k, m)])) + sig = -sign(r[k][k]) + u0 = r[k][k] - sig * norm + w = [[(r[i][k] / u0) if u0 else 1] for i in range(k, m)] + w[0][0] = 1 + t = (-sig * u0 / norm) if norm else 0 + wtw = matmul(w, [[x[0] * t for x in w]], dims=D2) + + # Capture householder reflections and tau + if mode_raw: + h.append(w) + tau[k] = t + + # Update R + sub_r = [r[i][:] for i in range(k, m)] + for count, row in enumerate(matmul(wtw, sub_r, dims=D2), k): + # Fill the lower triangle with zeros and update the upper triangle + r[count][:] = [r[count][col] - row[col] for col in range(n)] + + if not mode_r: + # Update Q + sub_q = [row[k:] for row in q] + for count, row in enumerate(matmul(sub_q, wtw, dims=D2)): + q[count][k:] = [sub_q[count][i] - row[i] for i in range(m - k)] + + # Zero out the lower triangle or fill with the householder reflectors if in raw mode + for k in range(0, m - 1 if not tall else n): + for j, i in enumerate(range(k + 1, m), 1): + r[i][k] = h[k][j][0] if mode_raw else 0.0 + + # Trim unnecessary columns and rows + if tall and not mode_complete and not empty: + for row in q: + del row[-diff:] + del r[-diff:] + elif wide: + del r[-diff:] + + # Return H (householder reflections in the lower half of R matrix) and tau values + if mode_raw: + return r, tau + + # Return either Q and R or just R depending on the mode + return r if mode_r else (q, r) + + +def qr( + a: MatrixLike | TensorLike, + mode: str = 'reduced' +) -> Any: + """ + QR decomposition using householder reflections. + + https://www.cs.cornell.edu/~bindel/class/cs6210-f09/lec18.pdf + + Generally this provides a similar interface to Numpy with the following modes: + + - "reduced": returns Q, R with dimensions `(…, M, K)`, `(…, K, N)` + - "complete": returns Q, R with dimensions `(…, M, M)`, `(…, M, N)` + - "r": returns R only with dimensions `(…, K, N)` + - "raw": returns h, tau with dimensions `(…, N, M)`, `(…, K,)` where + h is the R matrix with the householder reflections in the lower triangle. + Unlike Numpy, we do not provide the transposed matrix for Fortran. + """ + + if mode not in QR_MODES: + raise ValueError(f"Mode '{mode}' not recognized") + + s = shape(a) + dims = len(s) + mode_r = mode == 'r' or mode == 'raw' + + # Ensure we have at least a matrix + if dims < 2: + raise ValueError('Array must be at least 2 dimensional') + + # Handle stacked matrix cases + elif dims > 2: + last = s[-2:] # type: tuple[int, int] # type: ignore[misc] + first = s[:-2] # type: Shape # type: ignore[misc] + rows = [*_extract_rows(a, s)] + step = last[-2] + m, n = last + r = [] # type: Any + if not mode_r: + q = [] # type: Any + builder = MultiArrayBuilder([q, r], [first, first]) + else: + builder = MultiArrayBuilder([r], [first]) + with builder as arrays: + for ri in range(0, len(rows), step): + result = _qr(rows[ri:ri + step], m, n, mode) + if not mode_r: + next(arrays[0]).append(result[0]) + next(arrays[1]).append(result[1]) + else: + next(arrays[0]).append(result) + if mode_r: + return r + return q, r - for i in range(size - 1, -1, -1): - v = b[i] - for j in range(i + 1, size): - v -= a[i][j] * b[j] - b[i] = v / a[i][i] - return b + # Apply QR decomposition on a single matrix + return _qr(a, s[0], s[1], mode) # type: ignore[arg-type] -def _back_sub_matrix(a: Matrix, b: Matrix, s: Shape) -> Matrix: - """Back substitution for solution of `U x = b`.""" +def matrix_rank(a: MatrixLike | TensorLike) -> Any: + """Calculate the matrix rank.""" - size1, size2 = s - for i in range(size1 - 1, -1, -1): - v = b[i] # type: Any - for j in range(i + 1, size1): - for k in range(size2): - v[k] -= a[i][j] * b[j][k] - for j in range(size2): - b[i][j] /= a[i][i] - return b + s = shape(a) + dims = len(s) + last = s[-2:] # type: tuple[int, int] # type: ignore[misc] + rtol = max(last) * EPS + + if dims < 2: + raise ValueError('Array must be at least 2 dimensional') + + # Single matrix + if dims == 2: + rank = 0 + sigma = _svd(a, s[0], s[1], False, False) # type: ignore[arg-type] + tol = max(sigma) * rtol + for x in sigma: + if x > tol: + rank += 1 + return rank + + # Stack of matrices + first = s[:-2] # type: Shape # type: ignore[misc] + rows = [*_extract_rows(a, s)] + step = last[-2] + m, n = last + ranks = [] # type: Any + with ArrayBuilder(ranks, first) as build: + for r in range(0, len(rows), step): + sigma = _svd(rows[r:r + step], m, n, False, False) + rank = 0 + tol = max(sigma) * rtol + for x in sigma: + if x > tol: + rank += 1 + next(build).append(rank) + return ranks @overload @@ -2769,35 +4449,20 @@ def solve(a: MatrixLike, b: TensorLike) -> Tensor: @overload -def solve(a: TensorLike, b: MatrixLike | TensorLike) -> Tensor | Matrix: +def solve(a: TensorLike, b: VectorLike) -> Matrix | Tensor: ... -def solve(a: MatrixLike | TensorLike, b: ArrayLike) -> Array: - """ - Solve the system of equations. - - The return value always matches the shape of 'b' and `a' must be square - in the last two dimensions. - - Broadcasting is not quite done in the traditional way. - - 1. [M, M] and [M] will solve a set of linear equations against a vector - of dependent variables. - - 2. If we have [..., M, M] and [..., M, M] and it we have multiple sets of linear - equations it will be treated as as multiple [M, M] and [M] cases as described in 1). - - If we have only one set of linear equations, it will be treated as a [..., M, M] and - [..., M, K] case as described in 3). +@overload +def solve(a: TensorLike, b: MatrixLike | TensorLike) -> Tensor: + ... - 3. If we have [..., M, M] and [..., M, K], we will either solve a single set of linear - equations against multiple matrices of containing K dependent variable sets. - 4. Lastly, if we have [..., M, M] and [..., M], where we have N vectors that matches N [M, M] - equation sets, then we will solve one matrix with one vector. +def solve(a: MatrixLike | TensorLike, b: ArrayLike) -> Array: + """ + Solve the system of equations for `x` where `ax = b`. - Anything else "should" fail, one way or another. + Normal broadcasting applies and the behavior matches Numpy 2+. """ s = shape(a) @@ -2829,7 +4494,7 @@ def solve(a: MatrixLike | TensorLike, b: ArrayLike) -> Array: r = b[i] if len(r) != size2: raise ValueError('Mismatched dimensions') - ordered.append(list(r)) + ordered.append([*r]) s2 = (size, size2) # type: Shape return _back_sub_matrix(u, _forward_sub_matrix(l, ordered, s2), s2) @@ -2841,65 +4506,50 @@ def solve(a: MatrixLike | TensorLike, b: ArrayLike) -> Array: # More complex, deeply nested cases that require more analyzing s2 = shape(b) - sol_m = sol_v = False - x = [] # type: Any - - # Broadcast the solving - if s2[-2] == size: - if s2[-1] == size: - p1 = prod(s) - p2 = prod(s2) - # One matrix of equations with multiple series of M dependent variable sets (matrix) - sol_m = not p2 % p1 - # Multiple equations sets with a single series of dependent variables per equation set (vectors) - sol_v = not sol_m and not p1 % p2 - elif s2[-2] == size: - # One matrix of equations with multiple series of K dependent variable sets (matrix) - p1 = prod(s[:-1]) - p2 = prod(s2[:-1]) - sol_m = not p2 % p1 - elif s2[-1] == size: - # Multiple equations sets with a single series of dependent variables per equation set (vectors) - p1 = prod(s[:-2]) - p2 = prod(s2[:-1]) - sol_v = p1 == p2 - - # Matrix and matrices - if sol_m: - rows_equ = list(_extract_rows(a, s)) - ma = [rows_equ[r:r + size] for r in range(0, len(rows_equ), size)] - rows_sol = list(_extract_rows(b, s2)) - mb = [rows_sol[r:r + size] for r in range(0, len(rows_sol), size)] - ai_shape = s[-2:] - - p, l, u = lu(ma[0], p_indices=True, _shape=ai_shape) - - if prod(l[i][i] * u[i][i] for i in range(size)) == 0.0: - raise ValueError('Matrix is singular') - - for bi in mb: - bi = [list(bi[i]) for i in p] - s3 = (size, len(bi[0])) - x.append(_back_sub_matrix(u, _forward_sub_matrix(l, bi, s3), s3)) - return reshape(x, s2) # type: ignore[return-value] + m = [] # type: Any # Matrices and vectors - elif sol_v: - rows_equ = list(_extract_rows(a, s)) - ma = [rows_equ[r:r + size] for r in range(0, len(rows_equ), size)] - mv = list(_extract_rows(b, s2)) - ai_shape = s[-2:] - - for ai, vi in zip(ma, mv): - p, l, u = lu(ai, p_indices=True, _shape=ai_shape) + if dim1: + m_shape = s[-2:] # type: ignore[misc] + base_shape = s[:-2] # type: ignore[misc] + + with ArrayBuilder(m, base_shape) as build: + for idx in ndindex(base_shape): + ma = a # type: Any + for i in idx: + ma = ma[i] + + p, l, u = lu(ma, p_indices=True, _shape=m_shape) + + if prod(l[i][i] * u[i][i] for i in range(size)) == 0.0: # pragma: no cover + raise ValueError('Matrix is singular') + + next(build).append(_back_sub_vector(u, _forward_sub_vector(l, [b[i] for i in p], size), size)) # type: ignore[misc] + return m # type: ignore[no-any-return] + + # Matrices and matrices + new_shape = _broadcast_shape((s[:-1], s2[:-1]), max(dims - 1, len(s2) - 1)) # type: ignore[misc] + base_shape = new_shape[:-1] + a = broadcast_to(a, new_shape + s[-1:]) # type: ignore[assignment, arg-type, misc] + b = broadcast_to(b, new_shape + s2[-1:]) # type: ignore[assignment, arg-type, misc] + with ArrayBuilder(m, base_shape) as build: + for idx in ndindex(base_shape): + ma = a + for i in idx: + ma = ma[i] + mb = b # type: Any + for i in idx: + mb = mb[i] + + p, l, u = lu(ma, p_indices=True, _shape=s[-2:]) # type: ignore[misc] if prod(l[i][i] * u[i][i] for i in range(size)) == 0.0: raise ValueError('Matrix is singular') - x.append(_back_sub_vector(u, _forward_sub_vector(l, [vi[i] for i in p], size), size)) - return reshape(x, s2) # type: ignore[return-value] - - raise ValueError("Could not broadcast {} and {}".format(s, s2)) + bi = [[*mb[i]] for i in p] + s3 = (size, len(bi[0])) + next(build).append(_back_sub_matrix(u, _forward_sub_matrix(l, bi, s3), s3)) + return m # type: ignore[no-any-return] def trace(matrix: Matrix) -> float: @@ -2908,22 +4558,32 @@ def trace(matrix: Matrix) -> float: return sum(diag(matrix)) -def det(matrix: MatrixLike) -> Any: +@overload +def det(array: MatrixLike) -> float: + ... + + +@overload +def det(array: TensorLike) -> Vector: + ... + + +def det(array: MatrixLike | TensorLike) -> float | Vector: """Get the determinant.""" - s = shape(matrix) + s = shape(array) if len(s) < 2 or s[-1] != s[-2]: raise ValueError('Last two dimensions must be square') if len(s) == 2: size = s[0] - p, l, u = lu(matrix, _shape=s) + p, l, u = lu(array, _shape=s) swaps = size - trace(p) sign = (-1) ** (swaps - 1) if swaps else 1 dt = sign * prod(l[i][i] * u[i][i] for i in range(size)) return 0.0 if not dt else dt else: - last = s[-2:] - rows = list(_extract_rows(matrix, s)) + last = s[-2:] # type: ignore[misc] + rows = [*_extract_rows(array, s)] step = last[-2] return [det(rows[r:r + step]) for r in range(0, len(rows), step)] @@ -2944,17 +4604,19 @@ def inv(matrix: MatrixLike | TensorLike) -> Matrix | Tensor: # Ensure we have a square matrix s = shape(matrix) dims = len(s) - last = s[-2:] + last = s[-2:] # type: tuple[int, int] # type: ignore[misc] if dims < 2 or min(last) != max(last): raise ValueError('Matrix must be a N x N matrix') # Handle dimensions greater than 2 x 2 elif dims > 2: - invert = [] - rows = list(_extract_rows(matrix, s)) + invert = [] # type: Tensor step = last[-2] - invert = [inv(rows[r:r + step]) for r in range(0, len(rows), step)] - return reshape(invert, s) # type: ignore[return-value] + rows = [*_extract_rows(matrix, s)] + with ArrayBuilder(invert, s[:-2]) as build: # type: ignore[misc] + for r in range(0, len(rows), step): + next(build).append(inv(rows[r:r + step])) + return invert # Calculate the LU decomposition. size = s[0] @@ -2972,6 +4634,48 @@ def inv(matrix: MatrixLike | TensorLike) -> Matrix | Tensor: return _back_sub_matrix(u, _forward_sub_matrix(l, p, s2), s2) +@overload +def pinv(a: MatrixLike) -> Matrix: + ... + + +@overload +def pinv(a: TensorLike) -> Tensor: + ... + + +def pinv(a: MatrixLike | TensorLike) -> Matrix | Tensor: + """ + Compute the (Moore-Penrose) pseudo-inverse of a matrix use SVD. + + Negative results can be returned, use `fnnls` for a non-negative solution (if possible). + """ + + s = shape(a) + dims = len(s) + + # Ensure we have at least a matrix + if dims < 2: + raise ValueError('Array must be at least 2 dimensional') + + elif dims > 2: + last = s[-2:] # type: tuple[int, int] # type: ignore[misc] + invert = [] # type: Tensor + rows = [*_extract_rows(a, s)] + step = last[-2] + with ArrayBuilder(invert, s[:-2]) as build: # type: ignore[misc] + for r in range(0, len(rows), step): + next(build).append(pinv(rows[r:r + step])) + return invert + + m = s[0] + n = s[1] + u, sigma, v = _svd(a, m, n, full_matrices=False) # type: ignore[arg-type] + tol = max(sigma) * max(m, n) * EPS + sigma = [[1 / x if x > tol else x] for x in sigma] + return matmul(v, multiply(sigma, transpose(u), dims=D2), dims=D2) # type: ignore[no-any-return] + + @overload def vstack(arrays: Sequence[float | Vector | Matrix]) -> Matrix: ... @@ -3004,7 +4708,7 @@ def vstack(arrays: Sequence[ArrayLike | float]) -> Matrix | Tensor: dims = 2 elif dims == 1: a = [a] # type: ignore[assignment] - s = (1, s[0]) + s = (1, s[0]) # type: ignore[misc] dims = 2 # Verify that we can apply the stacking @@ -3018,7 +4722,7 @@ def vstack(arrays: Sequence[ArrayLike | float]) -> Matrix | Tensor: raise ValueError('All the input array dimensions except for the concatenation axis must match exactly') # Stack the arrays - m.extend(reshape(a, (prod(s[:1 - dims]),) + s[1 - dims:-1] + s[-1:])) # type: ignore[arg-type] + m.extend(reshape(a, (prod(s[:1 - dims]),) + s[1 - dims:-1] + s[-1:])) # type: ignore[arg-type, misc] # Update the last array tracker if not last or len(last) > len(s): @@ -3032,7 +4736,7 @@ def vstack(arrays: Sequence[ArrayLike | float]) -> Matrix | Tensor: return m -def _hstack_extract(a: ArrayLike | float, s: ShapeLike) -> Iterator[Array]: +def _hstack_extract(a: ArrayLike | float, s: ArrayShape) -> Iterator[Array]: """Extract data from the second axis.""" data = flatiter(a) @@ -3117,7 +4821,7 @@ def hstack(arrays: Sequence[ArrayLike | float]) -> Array: # Shape the data to the new shape new_shape = largest[:axis] + (columns,) + largest[axis + 1:] if len(largest) > 1 else (columns,) - return reshape(m, new_shape) # type: ignore[return-value] + return reshape(m, new_shape) # type: ignore[return-value, arg-type] def outer(a: float | ArrayLike, b: float | ArrayLike) -> Matrix: @@ -3127,6 +4831,86 @@ def outer(a: float | ArrayLike, b: float | ArrayLike) -> Matrix: return [[x * y for y in v2] for x in flatiter(a)] +@overload +def inner(a: float, b: float) -> float: + ... + + +@overload +def inner(a: float, b: VectorLike) -> Vector: + ... + + +@overload +def inner(a: VectorLike, b: float) -> Vector: + ... + + +@overload +def inner(a: float, b: MatrixLike) -> Matrix: + ... + + +@overload +def inner(a: MatrixLike, b: float) -> Matrix: + ... + + +@overload +def inner(a: float, b: TensorLike) -> Tensor: + ... + + +@overload +def inner(a: TensorLike, b: float) -> Tensor: + ... + + +@overload +def inner(a: VectorLike, b: VectorLike) -> float: + ... + + +@overload +def inner(a: VectorLike, b: MatrixLike) -> Vector: + ... + + +@overload +def inner(a: MatrixLike, b: VectorLike) -> Vector: + ... + + +@overload +def inner(a: VectorLike, b: TensorLike) -> Tensor | Matrix: + ... + + +@overload +def inner(a: TensorLike, b: VectorLike) -> Tensor | Matrix: + ... + + +@overload +def inner(a: MatrixLike, b: MatrixLike) -> Matrix: + ... + + +@overload +def inner(a: MatrixLike, b: TensorLike) -> Tensor | Matrix: + ... + + +@overload +def inner(a: TensorLike, b: MatrixLike) -> Tensor | Matrix: + ... + + +@overload +def inner(a: TensorLike, b: TensorLike) -> Tensor: + ... + + def inner(a: float | ArrayLike, b: float | ArrayLike) -> float | Array: """Compute the inner product of two arrays.""" @@ -3137,7 +4921,7 @@ def inner(a: float | ArrayLike, b: float | ArrayLike) -> float | Array: # If both inputs are not scalars, the last dimension must match if (shape_a and shape_b and shape_a[-1] != shape_b[-1]): - raise ValueError('The last dimensions {} and {} do not match'.format(shape_a, shape_b)) + raise ValueError(f'The last dimensions {shape_a} and {shape_b} do not match') # If we have a scalar, we should just multiply if (not dims_a or not dims_b): @@ -3156,13 +4940,398 @@ def inner(a: float | ArrayLike, b: float | ArrayLike) -> float | Array: if dims_b == 1: second = [b] # type: Any elif dims_b > 2: - second = list(_extract_rows(b, shape_b)) # type: ignore[arg-type] + second = [*_extract_rows(b, shape_b)] # type: ignore[arg-type] else: second = b # Perform the actual inner product m = [[sum([x * y for x, y in it.zip_longest(r1, r2)]) for r2 in second] for r1 in first] - new_shape = shape_a[:-1] + shape_b[:-1] + new_shape = shape_a[:-1] + shape_b[:-1] # type: ignore[misc] # Shape the data. - return reshape(m, new_shape) + return reshape(m, new_shape) # type: ignore[arg-type] + + +def fnnls( + A: MatrixLike, + b: VectorLike, + epsilon: float = 1e-12, + max_iters: int = 0 +) -> tuple[Vector, float]: + """ + Fast non-negative least squares. + + A fast non-negativity-constrained least squares + https://www.researchgate.net/publication/230554373_A_Fast_Non-negativity-constrained_Least_Squares_Algorithm + Rasmus Bro and Sijmen De Jong + Journal of Chemometrics. 11, 393–401 (1997) + """ + + m, n = _quick_shape(A) + + if m != len(b): + raise ValueError(f'Vector length of b must match first dimension of A: {m} != {len(b)}') + + if not max_iters: + max_iters = n * 30 + + AT = transpose(A) + ATA = dot(AT, A, dims=D2) + ATb = dot(AT, b, dims=D2_D1) + + x = [0.0] * n + s = [0.0] * n + w = subtract(ATb, dot(ATA, x, dims=D2_D1), dims=D1) # type: Vector + + # P tracks positive elements in x + # Does double duty as P and R vector outlined in the paper + P = [False] * n + + # Continue until all values of x are positive (non-negative results only) + # or we exhaust the iterations. + count = 0 + while sum(P) < n and max(w[i] for i in range(n) if not P[i]) > epsilon and count < max_iters: + # Find the index that maximizes w + # This will be an index not in P + imx = 0 + mx = -math.inf + for i in range(n): + if not P[i] and w[i] > mx: + imx = i + mx = w[i] + + P[imx] = True + + # Solve least squares problem for columns and rows not in P + idx = [i for i in range(n) if P[i]] + v = dot(inv([[ATA[i][j] for j in idx] for i in idx]), [ATb[i] for i in idx], dims=D2_D1) + for i, _v in zip(idx, v): + s[i] = _v + + # Deal with negative values + while _any([s[i] <= epsilon for i in range(n) if P[i]]): + count += 1 + + # Calculate step size, alpha, to prevent any x from going negative + alpha = min( + [zdiv(x[i], (x[i] - s[i]), math.inf) for i in range(n) if P[i] * (s[i] <= epsilon)] + ) + + # Update the solution + x = add(x, dot(alpha, subtract(s, x, dims=D1), dims=SC_D1), dims=D1) + + # Remove indexes in P where x == 0 + for i in range(n): + if x[i] <= epsilon: + P[i] = False + + # Solve least squares problem again + idx = [i for i in range(n) if P[i]] + v = dot(inv([[ATA[i][j] for j in idx] for i in idx]), [ATb[i] for i in idx], dims=D2_D1) + j = 0 + l = len(idx) + for i in range(n): + if j < l and i == idx[j]: + s[i] = v[j] + j += 1 + else: + s[i] = 0.0 + + # Update the solution + x = s[:] + w = subtract(ATb, dot(ATA, x, dims=D2_D1), dims=D1) + + # Return our final result, for better or for worse + res = math.hypot(*subtract(b, dot(A, x, dims=D2_D1), dims=D1)) + return x, res + + +@overload +def flip(a: float, axis: int | tuple[int, ...] | None = ...) -> float: + ... + + +@overload +def flip(a: VectorLike, axis: int | tuple[int, ...] | None = ...) -> Vector: + ... + + +@overload +def flip(a: MatrixLike, axis: int | tuple[int, ...] | None = ...) -> Matrix: + ... + + +@overload +def flip(a: TensorLike, axis: int | tuple[int, ...] | None = ...) -> Tensor: + ... + + +def flip(a: ArrayLike | float, axis: int | tuple[int, ...] | None = None) -> Array | float: + """Flip specified axis/axes.""" + + s = shape(a) + l = len(s) + + if not s: + return a # type: ignore[return-value] + + # Adjust axes + if axis is None: + axes = set(range(l)) + elif isinstance(axis, int): + axes = {l + axis if axis < 0 else axis} + else: + axes = set() + for ai in axis: + ai = l + ai if ai < 0 else ai + if ai in axes: + raise ValueError('Repeated axis') + axes.add(ai) + + m = acopy(a) # type: Array # type: ignore[arg-type] + indexes = [-1] * l + end = l - 1 + + # Check if axes are within bounds + for ax in axes: + if ax > end: + raise ValueError(f'Axis {ax} out of bounds of dimension {l}') + + # Flip the axes + for idx in ndindex(s[:-1] + (1,)): # type: ignore[misc] + t = m # type: Any + count = 0 + for i in idx: + if indexes[count] == -1: + if count in axes: + t[:] = t[::-1] + + if indexes[count] != i: + indexes[count] = i + indexes[count + 1:] = [-1] * (end - count) + count += 1 + t = t[i] + return m + + +@overload +def flipud(a: float) -> float: + ... + + +@overload +def flipud(a: VectorLike) -> Vector: + ... + + +@overload +def flipud(a: MatrixLike) -> Matrix: + ... + + +@overload +def flipud(a: TensorLike) -> Tensor: + ... + + +def flipud(a: ArrayLike | float) -> Array | float: + """Flip axis 0.""" + + return flip(a, axis=0) + + +@overload +def fliplr(a: float) -> float: + ... + + +@overload +def fliplr(a: VectorLike) -> Vector: + ... + + +@overload +def fliplr(a: MatrixLike) -> Matrix: + ... + + +@overload +def fliplr(a: TensorLike) -> Tensor: + ... + + +def fliplr(a: ArrayLike | float) -> Array | float: + """Flip axis 1.""" + + return flip(a, axis=1) + + +@overload +def roll(a: float, shift: int | tuple[int, ...], axis: int | tuple[int, ...] | None = ...) -> float: + ... + + +@overload +def roll(a: VectorLike, shift: int | tuple[int, ...], axis: int | tuple[int, ...] | None = ...) -> Vector: + ... + + +@overload +def roll(a: MatrixLike, shift: int | tuple[int, ...], axis: int | tuple[int, ...] | None = ...) -> Matrix: + ... + + +@overload +def roll(a: TensorLike, shift: int | tuple[int, ...], axis: int | tuple[int, ...] | None = ...) -> Tensor: + ... + + +def roll( + a: ArrayLike | float, + shift: int | tuple[int, ...], + axis: int | tuple[int, ...] | None = None +) -> Array | float: + """Roll specified axis/axes.""" + + s = shape(a) + + # Return floats + if not s: + return a # type: ignore[return-value] + + # Flatten data when no axis is specified and roll data + if axis is None: + if not isinstance(shift, int): + shift = sum(shift) + p = prod(s) + sgn = sign(shift) + shift = int(shift % (p * sgn)) if p and sgn else 0 + flat = ravel(a) if len(s) != 1 else [*a] # type: ignore[misc] + sh = -shift + flat[:] = flat[sh:] + flat[:sh] + return reshape(flat, s) + + axes = [axis] if isinstance(axis, int) else axis + m = acopy(a) # type: ignore[arg-type] + l = len(s) + indexes = [-1] * l + end = l - 1 + + # Broadcast the shifts and axes + new_shift = [] # type: VectorInt + new_axes = [] # type: VectorInt + for i, j in broadcast(shift, axes): + if j < 0: + j = l + j + sgn = sign(i) + new_shift.append(int(i % (s[j] * sgn)) if s[j] and sgn else 0) # type: ignore[call-overload] + new_axes.append(j) # type: ignore[arg-type] + + # Perform the roll across the specified axes + for idx in ndindex(s[:-1] + (1,)): # type: ignore[misc] + t = m # type: Any + count = 0 + for i in idx: + if indexes[count] == -1: + for e, ax in enumerate(new_axes): + if count == ax: + sh = -new_shift[e] + t[:] = t[sh:] + t[:sh] + + if indexes[count] != i: + indexes[count] = i + indexes[count + 1:] = [-1] * (end - count) + count += 1 + t = t[i] + return m + + +def unique( + a: ArrayLike | float, + axis: int | None = None, + return_index: bool = False, + return_inverse: bool = False, + return_counts: bool = False +) -> Any: + """Return unique elements.""" + + values = [] # type: list[Any] + indices = [] + inverse = [] + count = [] + offset = 0 + track = {} # type: dict[Any, int] + index = 0 + just_values = not return_index and not return_inverse and not return_counts + + # If no axis, flatten data + if axis is None: + for e, v in enumerate(flatiter(a)): + if v not in track: + values.append(v) + indices.append(e) + inverse.append(e - offset) + count.append(1) + track[v] = index + index += 1 + else: + offset += 1 + i = track[v] + inverse.append(i) + count[i] += 1 + + # Apply to higher axes + else: + s = shape(a) + l = len(s) + + # Ensure axis in bound + if axis > l - 1: + raise ValueError(f'Axis {axis} out of bounds of dimension {l}') + + track = {} + index = 0 + # Iterate array + for e, idx in enumerate(ndindex(s[:axis + 1])): + t = a # type: Any + for i in idx: + t = t[i] + + # Convert data into an object we can hash + d = [] + for idx in ndindex(s[axis + 1:]): + m = t # type: Any + for i in idx: + m = m[i] + d.append(m) + dt = tuple(d) + + if dt not in track: + values.append(d) + indices.append(e) + inverse.append(e - offset) + count.append(1) + track[dt] = index + index += 1 + else: + offset += 1 + i = track[dt] + inverse.append(i) + count[i] += 1 + + # Calculate sorting index + sargs = sorted(range(len(values)), key=values.__getitem__) + + # Return sorted values + if just_values: + return [values[si] for si in sargs] + + # Return sorted values with requested, index, inverse index, and/or count + result = [[values[si] for si in sargs]] # type: Any + if return_index: + result.append([indices[si] for si in sargs]) + if return_inverse: + result.append([sargs[i] for i in inverse]) + if return_counts: + result.append([count[si] for si in sargs]) + return tuple(result) diff --git a/lib/coloraide/average.py b/lib/coloraide/average.py index 36f0dc6..13173d7 100644 --- a/lib/coloraide/average.py +++ b/lib/coloraide/average.py @@ -1,77 +1,149 @@ """Average colors together.""" from __future__ import annotations import math -from .types import ColorInput -from typing import Iterable, TYPE_CHECKING +from . import util +import itertools as it +from .spaces import HWBish +from .types import ColorInput, AnyColor +from typing import Iterable -if TYPE_CHECKING: # pragma: no cover - from .color import Color + +class Sentinel(float): + """Sentinel object that is specific to averaging that we shouldn't see defined anywhere else.""" + + +def _iter_colors(colors: Iterable[ColorInput]) -> Iterable[tuple[ColorInput, float]]: + """Iterate colors and return weights.""" + + for c in colors: + yield c, 1.0 def average( - create: type[Color], + color_cls: type[AnyColor], colors: Iterable[ColorInput], + weights: Iterable[float] | None, space: str, - premultiplied: bool = True, - powerless: bool = False -) -> Color: - """Average a list of colors together.""" + premultiplied: bool = True +) -> AnyColor: + """ + Average a list of colors together. - obj = create(space, []) + Polar coordinates use a circular mean: https://en.wikipedia.org/wiki/Circular_mean. + """ + + sentinel = Sentinel() + obj = color_cls(space, []) # Get channel information cs = obj.CS_MAP[space] - hue_index = cs.hue_index() if cs.is_polar() else -1 # type: ignore[attr-defined] + if cs.is_polar(): + hue_index = cs.hue_index() # type: ignore[attr-defined] + is_hwb = isinstance(cs, HWBish) + else: + hue_index = -1 + is_hwb = False channels = cs.channels chan_count = len(channels) - alpha_index = chan_count - 1 - sums = [0.0] * chan_count - totals = [0.0] * chan_count + avgs = [0.0] * chan_count + counts = [0] * chan_count sin = 0.0 cos = 0.0 + wavg = 0.0 + no_weights = weights is None + if no_weights: + weights = () + mx = 0.0 - # Sum channel values - i = -1 - for c in colors: - obj.update(c) + # Sum channel values using a rolling average. Apply premultiplication and additional weighting as required. + count = 0 + for c, w in (_iter_colors(colors) if no_weights else it.zip_longest(colors, weights, fillvalue=sentinel)): # type: ignore[arg-type] + + # Handle explicit weighted cases + if not no_weights: + # If there are more weights than colors, ignore additional weights + if c is sentinel: + break + + # If there are less weights than colors, assume full weight for colors without weights + if w is sentinel: + w = mx + + # Negative weights are considered as zero weight + if w < 0.0: + w = 0.0 + + # Track the largest weight so we can populate colors with no weights + elif w > mx: + mx = w + + obj.update(c) # type: ignore[arg-type] # 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(): + if hue_index >= 0 and not math.isnan(obj[hue_index]) and obj.is_achromatic(): obj[hue_index] = math.nan coords = obj[:] + + # Average weights + count += 1 + wavg += (w - wavg) / count + + # Include alpha in average if it is defined. If not defined, skip, but assume color is opaque. alpha = coords[-1] if math.isnan(alpha): alpha = 1.0 - i = 0 - for coord in coords: - if not math.isnan(coord): - totals[i] += 1 + else: + counts[-1] += 1 + avgs[-1] += ((coords[-1] * w) - avgs[-1]) / counts[-1] + + # Color channels use the provided weight and alpha weighting if premultiply is enabled + wfactor = (alpha * w) if premultiplied else w + for i in range(chan_count - 1): + coord = coords[i] + # No need to include a color component if its value is undefined of alpha is zero + if not math.isnan(coord) and (premultiplied or alpha): + counts[i] += 1 + n = counts[i] if i == hue_index: rad = math.radians(coord) - sin += math.sin(rad) - cos += math.cos(rad) + sin += ((math.sin(rad) * wfactor) - sin) / n + cos += ((math.cos(rad) * wfactor) - cos) / n else: - sums[i] += (coord * alpha) if premultiplied and i != alpha_index else coord - i += 1 + avgs[i] += ((coord * wfactor) - avgs[i]) / n - if i == -1: + if not count: raise ValueError('At least one color must be provided in order to average colors') - # Get the mean - alpha = sums[-1] - alpha_t = totals[-1] - 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): + # Undo premultiplication and weighting to get the final color. + # Adjust a channel to be undefined if all values in channel were undefined or if it is an achromatic hue channel. + if not wavg: + wavg = math.nan + avgs[-1] = alpha = math.nan if not counts[-1] else avgs[-1] / wavg + if math.isnan(alpha): alpha = 1.0 + factor = (alpha * wavg) if premultiplied else wavg + for i in range(chan_count - 1): - total = totals[i] - if not total: - sums[i] = math.nan + if not counts[i] or not alpha: + avgs[i] = math.nan elif i == hue_index: - avg_theta = math.degrees(math.atan2(sin / total, cos / total)) - sums[i] = (avg_theta + 360) if avg_theta < 0 else avg_theta + sin /= factor + cos /= factor + # Combine polar parts into a degree + if abs(sin) < util.ACHROMATIC_THRESHOLD_SM and abs(cos) < util.ACHROMATIC_THRESHOLD_SM: + avgs[i] = math.nan + else: + avg_theta = math.degrees(math.atan2(sin, cos)) + avgs[i] = (avg_theta + 360) if avg_theta < 0 else avg_theta else: - sums[i] /= total * alpha if premultiplied else total + avgs[i] /= factor - # Return the color - return obj.update(space, sums[:-1], sums[-1]) + # Create the color. If polar and there is no defined hue, force an achromatic state. + color = obj.update(space, avgs[:-1], avgs[-1]) + if cs.is_polar(): + if is_hwb and math.isnan(color[hue_index]): + w, b = cs.indexes()[1:] + if color[w] + color[b] < 1: + color[w] = 1 - color[b] + elif math.isnan(color[hue_index]) and not math.isnan(color[cs.radial_index()]): # type: ignore[attr-defined] + color[cs.radial_index()] = 0 # type: ignore[attr-defined] + return color diff --git a/lib/coloraide/cat.py b/lib/coloraide/cat.py index 3dee43a..149661c 100644 --- a/lib/coloraide/cat.py +++ b/lib/coloraide/cat.py @@ -5,18 +5,25 @@ from . import algebra as alg import functools from .types import Matrix, VectorLike, Vector, Plugin +from typing import cast # From CIE 2004 Colorimetry T.3 and T.8 # B from https://en.wikipedia.org/wiki/Standard_illuminant#White_point +# ACES white point provided via ACES documentation +# `ASTM-E308-D65` provided by the associated paper. +# Many systems use 4 decimals instead of 5, particularly for D65 and D50 (most commonly used); +# we use 4 for D50 and D65 to match CSS, etc. WHITES = { "2deg": { "A": (0.44758, 0.40745), "B": (0.34842, 0.35161), "C": (0.31006, 0.31616), - "D50": (0.34570, 0.35850), # Use 4 digits like everyone + "D50": (0.34570, 0.35850), # Use 4 digits like everyone (0.34567, 0,35851) "D55": (0.33243, 0.34744), - "D65": (0.31270, 0.32900), # Use 4 digits like everyone + "D65": (0.31270, 0.32900), # Use 4 digits like everyone (0.31272, 0,32903) "D75": (0.29903, 0.31488), + "ACES-D60": (0.32168, 0.33767), + "ASTM-E308-D65": cast('tuple[float, float]', tuple(util.xyz_to_xyY([0.95047, 1.0, 1.08883])[:-1])), "E": (1 / 3, 1 / 3), "F2": (0.37210, 0.37510), "F7": (0.31290, 0.32920), @@ -36,7 +43,7 @@ "F3": (0.41761, 0.38324), "F11": (0.38541, 0.37123) } -} +} # type: dict[str, dict[str, tuple[float, float]]] def calc_adaptation_matrices( @@ -58,10 +65,10 @@ def calc_adaptation_matrices( http://www.brucelindbloom.com/index.html?Math.html """ - 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) + src = alg.matmul_x3(m, util.xy_to_xyz(w1), dims=alg.D2_D1) + dest = alg.matmul_x3(m, util.xy_to_xyz(w2), dims=alg.D2_D1) + m2 = alg.diag(alg.divide_x3(dest, src, dims=alg.D1)) + adapt = alg.matmul_x3(alg.solve(m, m2), m, dims=alg.D2) return adapt, alg.inv(adapt) @@ -115,11 +122,11 @@ def adapt(self, w1: tuple[float, float], w2: tuple[float, float], xyz: VectorLik # We are already using the correct white point if w1 == w2: - return list(xyz) + return [*xyz] a, b = sorted([w1, w2]) m, mi = self.get_adaptation_matrices(a, b) - return alg.matmul(mi if a != w1 else m, xyz, dims=alg.D2_D1) + return alg.matmul_x3(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 d21929a..b45e3a7 100644 --- a/lib/coloraide/channels.py +++ b/lib/coloraide/channels.py @@ -1,5 +1,7 @@ """Channels.""" from __future__ import annotations +from typing import Callable +from . import algebra as alg FLG_ANGLE = 1 FLG_PERCENT = 2 @@ -16,7 +18,7 @@ class Channel(str): offset: float bound: bool flags: int - limit: tuple[float | None, float | None] + limit: Callable[[float], float | int] nans: float def __new__( @@ -24,12 +26,11 @@ def __new__( name: str, low: float, high: float, - mirror_range: bool = False, bound: bool = False, flags: int = 0, - limit: tuple[float | None, float | None] = (None, None), + limit: Callable[[float], float | int] | tuple[float | None, float | None] | None = None, nans: float = 0.0 - ) -> 'Channel': + ) -> Channel: """Initialize.""" obj = super().__new__(cls, name) @@ -40,6 +41,12 @@ def __new__( obj.offset = 0.0 if mirror else -low obj.bound = bound obj.flags = flags + # If nothing is provided, assume casting to float + if limit is None: + limit = float + # If a tuple of min/max is provided, create a function to clamp to the range + elif isinstance(limit, tuple): + limit = lambda x, l=limit: float(alg.clamp(x, l[0], l[1])) # type: ignore[misc] obj.limit = limit obj.nans = nans diff --git a/lib/coloraide/color.py b/lib/coloraide/color.py index d319aed..4e8623c 100644 --- a/lib/coloraide/color.py +++ b/lib/coloraide/color.py @@ -1,5 +1,6 @@ """Colors.""" from __future__ import annotations +import sys import abc import functools import random @@ -17,6 +18,7 @@ from . import temperature from . import util from . import algebra as alg +from .deprecate import deprecated, warn_deprecated from itertools import zip_longest as zipl from .css import parse from .types import VectorLike, Vector, ColorInput @@ -45,9 +47,9 @@ 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 .spaces.jzazbz.css import Jzazbz +from .spaces.jzczhz.css import JzCzhz +from .spaces.ictcp.css import ICtCp from .distance import DeltaE from .distance.delta_e_76 import DE76 from .distance.delta_e_94 import DE94 @@ -60,10 +62,9 @@ from .contrast import ColorContrast from .contrast.wcag21 import WCAG21Contrast from .gamut import Fit +from .gamut.fit_minde_chroma import MINDEChroma 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 @@ -80,7 +81,11 @@ from .temperature.ohno_2013 import Ohno2013 from .temperature.robertson_1968 import Robertson1968 from .types import Plugin -from typing import overload, Sequence, Iterable, Any, Callable, Mapping +from typing import Iterator, overload, Sequence, Iterable, Any, Callable, Mapping +if (3, 11) <= sys.version_info: + from typing import Self +else: + from typing_extensions import Self SUPPORTED_CHROMATICITY_SPACES = {'xyz', 'uv-1960', 'uv-1976', 'xy-1931'} @@ -100,7 +105,7 @@ def __init__(self, color: Color, start: int, end: int) -> None: def __str__(self) -> str: # pragma: no cover """String.""" - return "ColorMatch(color={!r}, start={}, end={})".format(self.color, self.start, self.end) + return f"ColorMatch(color={self.color!r}, start={self.start}, end={self.end})" __repr__ = __str__ @@ -119,7 +124,7 @@ def __init__(cls, name: str, bases: tuple[object, ...], clsdict: dict[str, Any]) 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.INTERPOLATE_MAP = cls.INTERPOLATE_MAP.copy() # type: dict[str, Interpolate[Any]] cls.CCT_MAP = cls.CCT_MAP.copy() # type: dict[str, CCT] # Ensure each derived class tracks its own conversion paths for color spaces @@ -147,9 +152,10 @@ class Color(metaclass=ColorMeta): CAT_MAP = {} # type: dict[str, CAT] CONTRAST_MAP = {} # type: dict[str, ColorContrast] FILTER_MAP = {} # type: dict[str, Filter] - INTERPOLATE_MAP = {} # type: dict[str, Interpolate] + INTERPOLATE_MAP = {} # type: dict[str, Interpolate[Self]] CCT_MAP = {} # type: dict[str, CCT] PRECISION = util.DEF_PREC + ROUNDING = util.DEF_ROUND_MODE FIT = util.DEF_FIT INTERPOLATE = util.DEF_INTERPOLATE INTERPOLATOR = util.DEF_INTERPOLATOR @@ -186,7 +192,12 @@ def __init__( def __len__(self) -> int: """Get number of channels.""" - return len(self._space.CHANNELS) + 1 + return len(self._space.channels) + + def __iter__(self) -> Iterator[float]: + """Initialize iterator.""" + + return iter(self._coords) @overload def __getitem__(self, i: str | int) -> float: @@ -215,16 +226,16 @@ def __setitem__(self, i: str | int | slice, v: float | Vector) -> None: space = self._space if isinstance(i, slice): for index, value in zip(range(len(self._coords))[i], v): # type: ignore[arg-type] - self._coords[index] = alg.clamp(float(value), *space.channels[index].limit) + self._coords[index] = space.channels[index].limit(value) else: index = space.get_channel_index(i) if isinstance(i, str) else i - self._coords[index] = alg.clamp(float(v), *space.channels[index].limit) # type: ignore[arg-type] + self._coords[index] = space.channels[index].limit(v) # type: ignore[arg-type] def __eq__(self, other: Any) -> bool: """Compare equal.""" return ( - type(other) == type(self) and + type(other) is type(self) and other.space() == self.space() and util.cmp_coords(other[:], self[:]) ) @@ -244,32 +255,32 @@ def _parse( # Parse a color space name and coordinates if data is not None: - s = color - space_class = cls.CS_MAP.get(s) + space_class = cls.CS_MAP.get(color) if not space_class: - raise ValueError("'{}' is not a registered color space".format(s)) + raise ValueError(f"'{color}' is not a registered color space") num_channels = len(space_class.CHANNELS) num_data = len(data) if num_data < num_channels: - 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)) + data = [*data, *[math.nan] * (num_channels - num_data)] + coords = [c.limit(v) for c, v in zipl(space_class.CHANNELS, data)] + coords.append(space_class.channels[-1].limit(alpha)) obj = space_class, coords # Parse a CSS string else: m = cls._match(color, fullmatch=True) if m is None: - raise ValueError("'{}' is not a valid color".format(color)) - coords = [alg.clamp(float(v), *c.limit) for c, v in zipl(m[0].CHANNELS, m[1])] - coords.append(alg.clamp(float(m[2]), *m[0].channels[-1].limit)) + raise ValueError(f"'{color}' is not a valid color") + coords = [c.limit(v) for c, v in zipl(m[0].CHANNELS, m[1])] + coords.append(m[0].channels[-1].limit(m[2])) obj = m[0], coords # Handle a color instance elif isinstance(color, Color): - space_class = cls.CS_MAP.get(color.space()) - if not space_class: - raise ValueError("'{}' is not a registered color space".format(color.space())) + cs = color._space + space_class = cls.CS_MAP.get(cs.NAME) + if not space_class or type(cs) is not type(space_class): + raise ValueError(f"{type(cs)} is not a registered color space within {cls}") obj = space_class, color[:] # Handle a color dictionary @@ -277,7 +288,7 @@ def _parse( obj = cls._parse(color['space'], color['coords'], color.get('alpha', 1.0)) else: - raise TypeError("'{}' is an unrecognized type".format(type(color))) + raise TypeError(f"{type(color)} is an unrecognized type") return obj @@ -384,14 +395,14 @@ def register( else: if reset_convert_cache: # pragma: no cover cls._get_convert_chain.cache_clear() - raise TypeError("Cannot register plugin of type '{}'".format(type(i))) + raise TypeError(f"Cannot register plugin of type '{type(i)}'") - if p.NAME != "*" and p.NAME not in mapping or overwrite: + if p.NAME != "*" and (p.NAME not in mapping or overwrite): mapping[p.NAME] = p elif not silent: if reset_convert_cache: # pragma: no cover cls._get_convert_chain.cache_clear() - raise ValueError("A plugin of name '{}' already exists or is not allowed".format(p.NAME)) + raise ValueError(f"A plugin of name '{p.NAME}' already exists or is not allowed") if reset_convert_cache: cls._get_convert_chain.cache_clear() @@ -441,13 +452,13 @@ def deregister(cls, plugin: str | Sequence[str], *, silent: bool = False) -> Non cls._get_convert_chain.cache_clear() if not silent: raise ValueError( - "'{}' is a reserved name gamut mapping/reduction and cannot be removed".format(name) + f"'{name}' is a reserved name gamut mapping/reduction and cannot be removed" ) continue # pragma: no cover else: if reset_convert_cache: # pragma: no cover cls._get_convert_chain.cache_clear() - raise ValueError("The plugin category of '{}' is not recognized".format(ptype)) + raise ValueError(f"The plugin category of '{ptype}' is not recognized") if name == '*': mapping.clear() @@ -456,13 +467,13 @@ def deregister(cls, plugin: str | Sequence[str], *, silent: bool = False) -> Non elif not silent: if reset_convert_cache: cls._get_convert_chain.cache_clear() - raise ValueError("A plugin of name '{}' under category '{}' could not be found".format(name, ptype)) + raise ValueError(f"A plugin of name '{name}' under category '{ptype}' could not be found") if reset_convert_cache: cls._get_convert_chain.cache_clear() @classmethod - def random(cls, space: str, *, limits: Sequence[Sequence[float] | None] | None = None) -> Color: + def random(cls, space: str, *, limits: Sequence[Sequence[float] | None] | None = None) -> Self: """Get a random color.""" # Get the color space and number of channels @@ -473,7 +484,7 @@ def random(cls, space: str, *, limits: Sequence[Sequence[float] | None] | None = if limits is None: limits = [] - # Acquire the minimum and maximum for the channel and get a random value value between + # Acquire the minimum and maximum for the channel and get a random value between length = len(limits) coords = [] for i in range(num_chan): @@ -503,7 +514,7 @@ def blackbody( scale_space: str | None = None, method: str | None = None, **kwargs: Any - ) -> Color: + ) -> Self: """ Get a color along the black body curve. @@ -527,12 +538,27 @@ def cct(self, *, method: str | None = None, **kwargs: Any) -> Vector: cct = temperature.cct(method, self) return cct.to_cct(self, **kwargs) - def to_dict(self, *, nans: bool = True) -> Mapping[str, Any]: + def to_dict( + self, + *, + nans: bool = True, + precision: int | Sequence[int] | None = None, + rounding: str | None = None + ) -> Mapping[str, Any]: """Return color as a data object.""" - return {'space': self.space(), 'coords': self.coords(nans=nans), 'alpha': self.alpha(nans=nans)} + if precision is None or isinstance(precision, int): + precision_alpha = precision + else: + precision_alpha = util.get_index(precision, len(self._space.channels) - 1, self.PRECISION) + + return { + 'space': self.space(), + 'coords': self.coords(nans=nans, precision=precision, rounding=rounding), + 'alpha': self.alpha(nans=nans, precision=precision_alpha, rounding=rounding) + } - def normalize(self, *, nans: bool = True) -> Color: + def normalize(self, *, nans: bool = True) -> Self: """Normalize the color.""" self[:-1] = self._space.normalize(self.coords(nans=False)) @@ -548,33 +574,35 @@ def is_nan(self, name: str) -> bool: # pragma: no cover return math.isnan(self.get(name)) - def _handle_color_input(self, color: ColorInput) -> Color: + @classmethod + def _handle_color_input(cls, color: ColorInput) -> Self: """Handle color input.""" if isinstance(color, (str, Mapping)): - return self.new(color) - elif self._is_color(color): - return color if self._is_this_color(color) else self.new(color) + return cls.new(color) + elif cls._is_color(color): + return color if cls._is_this_color(color) else cls.new(color) # type: ignore[return-value] else: - raise TypeError("Unexpected type '{}'".format(type(color))) + raise TypeError(f"Unexpected type '{type(color)}'") def space(self) -> str: """The current color space.""" return self._space.NAME + @classmethod def new( - self, + cls, color: ColorInput, data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, **kwargs: Any - ) -> Color: + ) -> Self: """Create new color object.""" - return type(self)(color, data, alpha, **kwargs) + return cls(color, data, alpha, **kwargs) - def clone(self) -> Color: + def clone(self) -> Self: """Clone.""" return self.new(self.space(), self[:-1], self[-1]) @@ -586,30 +614,28 @@ def convert( fit: bool | str = False, in_place: bool = False, norm: bool = True - ) -> Color: + ) -> Self: """Convert to color space.""" - # Convert the color and then fit it. - if fit: - 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(method=method) - # Nothing to do, just return the color with no alterations. if space == self.space(): return self if in_place else self.clone() # Actually convert the color - c, coords = convert.convert(self, space) this = self if in_place else self.clone() - this._space = c - this._coords[:-1] = coords + this._space, this._coords[:-1] = convert.convert(self, space) # Normalize achromatic colors, but skip if we internally don't need this. if norm and this._space.is_polar() and this.is_achromatic(): this[this._space.hue_index()] = math.nan # type: ignore[attr-defined] + # Fit the color if required + if fit and not this.in_gamut(tolerance=0.0): + warn_deprecated( + "The 'fit' parameter in convert() has been deprecated, please call color.convert(space).fit() instead" + ) + this.fit(**(fit if isinstance(fit, dict) else {'method': None if fit is True else fit})) + return this def is_achromatic(self) -> bool: @@ -627,7 +653,7 @@ def mutate( data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, **kwargs: Any - ) -> Color: + ) -> Self: """Mutate the current color to a new color.""" self._space, self._coords = self._parse(color, data=data, alpha=alpha, **kwargs) @@ -641,7 +667,7 @@ def update( *, norm: bool = True, **kwargs: Any - ) -> Color: + ) -> Self: """Update the existing color space with the provided color.""" space = self.space() @@ -650,7 +676,7 @@ def update( self.convert(space, in_place=True, norm=norm) return self - def _hotswap(self, color: Color) -> Color: + def _hotswap(self, color: Color) -> Self: """ Hot swap a color object. @@ -670,12 +696,41 @@ def __repr__(self) -> str: return 'color({} {} / {})'.format( self._space._serialize()[0], - ' '.join([util.fmt_float(coord, util.DEF_PREC) for coord in self[:-1]]), + ' '.join([util.fmt_float(coord, util.DEF_PREC, util.DEF_ROUND_MODE) for coord in self[:-1]]), util.fmt_float(self[-1], util.DEF_PREC) ) __str__ = __repr__ + def _repr_html_(self) -> str: # pragma: no cover + """ + Return an HTML representation of the color for Jupyter and other aware libraries. + + Colors are not gamut mapped, but returned as is. + """ + + svg = ''.join( + [ + "", + "", + "", + "" + ] + ) + + return ''.join( + [ + '
', + '
', + '
', + '
', + f'
{self.to_string(fit=False)}
' + '
' + ] + ) + def white(self, cspace: str = 'xyz') -> Vector: """Get the white point.""" @@ -721,7 +776,7 @@ def split_chromaticity( if cspace == 'xyz': raise ValueError('XYZ is not a luminant-chromaticity color space.') - # Convert to the the requested uv color space if required. + # Convert to the requested uv color space if required. return ( self.convert_chromaticity('xyz', cspace, coords, white=white) if cspace != 'xy_1931' else coords ) @@ -736,7 +791,7 @@ def chromaticity( scale: bool = False, scale_space: str | None = None, white: VectorLike | None = None - ) -> Color: + ) -> Self: """ Create a color from chromaticity coordinates. @@ -799,18 +854,18 @@ def convert_chromaticity( # Check that we know the requested spaces if cspace1 not in SUPPORTED_CHROMATICITY_SPACES: - raise ValueError("Unexpected chromaticity space '{}'".format(cspace1)) + raise ValueError(f"Unexpected chromaticity space '{cspace1}'") if cspace2 not in SUPPORTED_CHROMATICITY_SPACES: - raise ValueError("Unexpected chromaticity space '{}'".format(cspace2)) + raise ValueError(f"Unexpected chromaticity space '{cspace2}'") # Return if there is nothing to convert l = len(coords) if (cspace1 == 'xyz' and l != 3) or l not in (2, 3): - raise ValueError('Unexpected number of coordinates ({}) for {}'.format(l, cspace1)) + raise ValueError(f'Unexpected number of coordinates ({l}) for {cspace1}') # Return if already in desired form if cspace1 == cspace2: - return list(coords) + [1] if l == 2 else list(coords) + return [*coords, 1] if l == 2 else [*coords] # If starting space is XYZ, then convert to xy if cspace1 == 'xyz': @@ -841,7 +896,7 @@ def convert_chromaticity( if target == 'xyz': return util.xy_to_xyz(pair, Y) - return list(pair) + [Y] + return [*pair, Y] @classmethod def chromatic_adaptation( @@ -856,11 +911,11 @@ def chromatic_adaptation( adapter = cls.CAT_MAP.get(method if method is not None else cls.CHROMATIC_ADAPTATION) if not adapter: - raise ValueError("'{}' is not a supported CAT".format(method)) + raise ValueError(f"'{method}' is not a supported CAT") return adapter.adapt(tuple(w1), tuple(w2), xyz) # type: ignore[arg-type] - def clip(self, space: str | None = None) -> Color: + def clip(self, space: str | None = None) -> Self: """Clip the color channels.""" orig_space = self.space() @@ -898,7 +953,7 @@ def fit( *, method: str | None = None, **kwargs: Any - ) -> Color: + ) -> Self: """Fit the gamut using the provided method.""" if method is None: @@ -924,7 +979,7 @@ def fit( mapping = self.FIT_MAP.get(method) if not mapping: # Unknown fit method - raise ValueError("'{}' gamut mapping is not currently supported".format(method)) + raise ValueError(f"'{method}' gamut mapping is not currently supported") mapping.fit(self, target, **kwargs) return self @@ -954,12 +1009,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) -> Self: """Check if in pointer gamut.""" return gamut.pointer.fit_pointer_gamut(self) - def mask(self, channel: str | Sequence[str], *, invert: bool = False, in_place: bool = False) -> Color: + def mask(self, channel: str | Sequence[str], *, invert: bool = False, in_place: bool = False) -> Self: """Mask color channels.""" this = self if in_place else self.clone() @@ -979,7 +1034,7 @@ def mix( *, in_place: bool = False, **interpolate_args: Any - ) -> Color: + ) -> Self: """ Mix colors using interpolation. @@ -992,9 +1047,7 @@ def mix( if domain is not None: interpolate_args['domain'] = interpolate.normalize_domain(domain) - if not self._is_color(color) and not isinstance(color, (str, Mapping)): - raise TypeError("Unexpected type '{}'".format(type(color))) - mixed = self.interpolate([self, color], **interpolate_args)(percent) + mixed = self.interpolate([self, color], **interpolate_args)(percent) # type: Self return self._hotswap(mixed) if in_place else mixed @classmethod @@ -1008,7 +1061,7 @@ def steps( delta_e: str | None = None, delta_e_args: dict[str, Any] | None = None, **interpolate_args: Any - ) -> list[Color]: + ) -> list[Self]: """Discrete steps.""" # Scale really needs to be between 0 and 1 or steps will break @@ -1032,7 +1085,7 @@ def discrete( delta_e_args: dict[str, Any] | None = None, domain: Vector | None = None, **interpolate_args: Any - ) -> Interpolator: + ) -> Interpolator[Self]: """Create a discrete interpolation.""" # If no steps were provided, use the number of colors provided @@ -1063,7 +1116,7 @@ def interpolate( carryforward: bool | None = None, powerless: bool | None = None, **kwargs: Any - ) -> Interpolator: + ) -> Interpolator[Self]: """ Return an interpolation function. @@ -1077,8 +1130,8 @@ def interpolate( """ return interpolate.interpolator( - method if method is not None else cls.INTERPOLATOR, cls, + method if method is not None else cls.INTERPOLATOR, colors=colors, space=space, out_space=out_space, @@ -1097,13 +1150,14 @@ def interpolate( def average( cls, colors: Iterable[ColorInput], + weights: Iterable[float] | None = None, *, space: str | None = None, out_space: str | None = None, premultiplied: bool = True, powerless: bool | None = None, **kwargs: Any - ) -> Color: + ) -> Self: """Average the colors.""" if space is None: @@ -1112,12 +1166,15 @@ def average( if out_space is None: out_space = space + if powerless is not None: # pragma: no cover + warn_deprecated("The use of 'powerless' with 'average()' is deprecated as it is now always enabled") + return average.average( cls, colors, + weights, space, - premultiplied, - powerless if powerless is not None else cls.POWERLESS, + premultiplied ).convert(out_space, in_place=True) def filter( # noqa: A003 @@ -1129,7 +1186,7 @@ def filter( # noqa: A003 out_space: str | None = None, in_place: bool = False, **kwargs: Any - ) -> Color: + ) -> Self: """Filter.""" return filters.filters(self, name, amount, space, out_space, in_place, **kwargs) @@ -1141,7 +1198,7 @@ def harmony( space: str | None = None, out_space: str | None = None, **kwargs: Any - ) -> list[Color]: + ) -> list[Self]: """Acquire the specified color harmonies.""" if space is None: @@ -1152,6 +1209,7 @@ def harmony( return [c.convert(out_space, in_place=True) for c in harmonies.harmonize(self, name, space, **kwargs)] + @deprecated('Please use the class method Color.layer([source, backdrop])') def compose( self, backdrop: ColorInput | Sequence[ColorInput], @@ -1161,17 +1219,36 @@ def compose( space: str | None = None, out_space: str | None = None, in_place: bool = False - ) -> Color: + ) -> Self: # pragma: no cover """Blend colors using the specified blend mode.""" if not isinstance(backdrop, str) and isinstance(backdrop, Sequence): - bcolor = [self._handle_color_input(c) for c in backdrop] + colors = [self._handle_color_input(c) for c in backdrop] + colors.insert(0, self) else: - bcolor = [self._handle_color_input(backdrop)] + colors = [self, self._handle_color_input(backdrop)] - color = compositing.compose(self, bcolor, blend, operator, space, out_space) + color = compositing.compose(type(self), colors, blend, operator, space, out_space) return self._hotswap(color) if in_place else color + @classmethod + def layer( + cls, + colors: Sequence[ColorInput], + *, + blend: str | bool = True, + operator: str | bool = True, + space: str | None = None, + out_space: str | None = None + ) -> Self: + """ + Apply color compositing (blend modes and alpha blending) on a list of colors. + + Colors are overlaid on each other with left being the top of the stack and right being the bottom of the stack. + """ + + return compositing.compose(cls, colors, blend, operator, space, out_space) + def delta_e( self, color: ColorInput, @@ -1187,7 +1264,7 @@ def delta_e( delta = self.DE_MAP.get(method) if not delta: - raise ValueError("'{}' is not currently a supported distancing algorithm.".format(method)) + raise ValueError(f"'{method}' is not currently a supported distancing algorithm.") return delta.distance(self, color, **kwargs) def distance(self, color: ColorInput, *, space: str = "lab") -> float: @@ -1201,7 +1278,7 @@ def closest( *, method: str | None = None, **kwargs: Any - ) -> Color: + ) -> Self: """Find the closest color to the current base color.""" return distance.closest(self, colors, method=method, **kwargs) @@ -1230,32 +1307,65 @@ def contrast(self, color: ColorInput, method: str | None = None) -> float: return contrast.contrast(method, self, color) @overload - def get(self, name: str, *, nans: bool = True) -> float: + def get(self, + name: str, + *, + nans: bool = ..., + precision: int | Sequence[int] | None = ..., + rounding: str | None = ... + ) -> float: ... @overload - def get(self, name: list[str] | tuple[str, ...], *, nans: bool = True) -> Vector: + def get( + self, + name: list[str] | tuple[str, ...], + *, + nans: bool = ..., + precision: int | Sequence[int] | None = ..., + rounding: str | None = ... + ) -> Vector: ... - def get(self, name: str | list[str] | tuple[str, ...], *, nans: bool = True) -> float | Vector: + def get( + self, + name: str | list[str] | tuple[str, ...], + *, + nans: bool = True, + precision: int | Sequence[int] | None = None, + rounding: str | None = None + ) -> float | Vector: """Get channel.""" + if rounding is None: + rounding = self.ROUNDING + is_plist = precision is not None and not isinstance(precision, int) + # Handle single channel if isinstance(name, str): # Handle space.channel if '.' in name: space, channel = name.split('.', 1) + obj = self.convert(space, norm=nans) if nans: - return self.convert(space)[channel] + v = obj[channel] else: - obj = self.convert(space, norm=nans) i = obj._space.get_channel_index(channel) - return obj._space.resolve_channel(i, obj._coords) + v = obj._space.resolve_channel(i, obj._coords) elif nans: - return self[name] + v = self[name] else: i = self._space.get_channel_index(name) - return self._space.resolve_channel(i, self._coords) + v = self._space.resolve_channel(i, self._coords) + + if precision is None: + return v + + return alg.round_to( + v, + util.get_index(precision, 0) if is_plist else precision, # type: ignore[arg-type] + rounding + ) # Handle list of channels else: @@ -1263,17 +1373,29 @@ def get(self, name: str | list[str] | tuple[str, ...], *, nans: bool = True) -> obj = self values = [] - for n in name: + for e, n in enumerate(name): # Handle space.channel space, channel = n.split('.', 1) if '.' in n else (original_space, n) if space != current_space: obj = self if space == original_space else self.convert(space, norm=nans) current_space = space if nans: - values.append(obj[channel]) + v = obj[channel] else: i = obj._space.get_channel_index(channel) - values.append(obj._space.resolve_channel(i, obj._coords)) + v = obj._space.resolve_channel(i, obj._coords) + + if precision is None: + values.append(v) + continue + + values.append( + alg.round_to( + v, + util.get_index(precision, e) if is_plist else precision, # type: ignore[arg-type] + rounding + ) + ) return values def set( # noqa: A003 @@ -1282,7 +1404,7 @@ def set( # noqa: A003 value: float | Callable[..., float] | None = None, *, nans: bool = True - ) -> Color: + ) -> Self: """Set channel.""" # Set all the channels in a dictionary. @@ -1290,7 +1412,7 @@ def set( # noqa: A003 # when dealing with different color spaces. if value is None: if isinstance(name, str): - raise ValueError("Missing the positional 'value' argument for channel '{}'".format(name)) + raise ValueError(f"Missing the positional 'value' argument for channel '{name}'") original_space = current_space = self.space() obj = self.clone() @@ -1335,21 +1457,51 @@ def set( # noqa: A003 return self - def coords(self, *, nans: bool = True) -> Vector: + def coords( + self, + *, + nans: bool = True, + precision: int | Sequence[int] | None = None, + rounding: str | None = None + ) -> Vector: """Get the color channels and optionally remove undefined values.""" - if nans: - return self[:-1] - else: - return [self._space.resolve_channel(index, self._coords) for index in range(len(self._coords) - 1)] - - def alpha(self, *, nans: bool = True) -> float: + # Full precision + if precision is None: + if nans: + return self[:-1] + else: + return [ + self._space.resolve_channel(index, self._coords) + for index in range(len(self._coords) - 1) + ] + + pint = isinstance(precision, int) + if rounding is None: + rounding = self.ROUNDING + + return [ + alg.round_to( + self[index] if nans else self._space.resolve_channel(index, self._coords), + precision if pint else util.get_index(precision, index, self.PRECISION), # type: ignore[arg-type] + rounding + ) + for index in range(len(self._coords) - 1) + ] + + def alpha( + self, + *, + nans: bool = True, + precision: int | None = None, + rounding: str | None = None + ) -> float: """Get the alpha channel.""" - if nans: - return self[-1] - else: - return self._space.resolve_channel(-1, self._coords) + value = self[-1] if nans else self._space.resolve_channel(-1, self._coords) + if precision is None: + return value + return alg.round_to(value, precision, self.ROUNDING if rounding is None else rounding) if precision else value Color.register( @@ -1397,11 +1549,10 @@ def alpha(self, *, nans: bool = True) -> float: DEZ(), # Fit + MINDEChroma(), LChChroma(), OkLChChroma(), RayTrace(), - LChRayTrace(), - OkLChRayTrace(), # Filters Sepia(), diff --git a/lib/coloraide/compositing/__init__.py b/lib/coloraide/compositing/__init__.py index b777a21..f055f39 100644 --- a/lib/coloraide/compositing/__init__.py +++ b/lib/coloraide/compositing/__init__.py @@ -9,20 +9,19 @@ from . import blend_modes from .. import algebra as alg from ..channels import Channel -from typing import TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from ..types import Vector, ColorInput, AnyColor +from typing import Sequence def clip_channel(coord: float, channel: Channel) -> float: """Clipping channel.""" - a = channel.low # type: float | None - b = channel.high # type: float | None + if channel.bound: + a = channel.low # type: float | None + b = channel.high # type: float | None # These parameters are unbounded - if not channel.bound: # pragma: no cover + else: # pragma: no cover # Will not execute unless we have a space that defines some coordinates # as bound and others as not. We do not currently have such spaces. a = None @@ -33,18 +32,19 @@ def clip_channel(coord: float, channel: Channel) -> float: def apply_compositing( - color1: Color, - color2: Color, + color1: Vector, + color2: Vector, + channels: tuple[Channel, ...], blender: blend_modes.Blend | None, operator: str | bool -) -> Color: +) -> Vector: """Perform the actual blending.""" # Get the color coordinates - csa = color1.alpha(nans=False) - cba = color2.alpha(nans=False) - coords1 = color1.coords(nans=False) - coords2 = color2.coords(nans=False) + csa = color1[-1] + cba = color2[-1] + coords1 = color1[:-1] + coords2 = color2[:-1] # Setup compositing compositor = None # type: porter_duff.PorterDuff | None @@ -56,9 +56,6 @@ def apply_compositing( compositor = porter_duff.compositor('source-over')(cba, csa) cra = alg.clamp(compositor.ao(), 0, 1) - # Perform compositing - channels = color1._space.CHANNELS - # Blend each channel. Afterward, clip and apply alpha compositing. i = 0 for cb, cr in zip(coords2, blender.blend(coords2, coords1) if blender else coords1): @@ -76,15 +73,18 @@ def apply_compositing( def compose( - color: Color, - backdrop: list[Color], + color_cls: type[AnyColor], + colors: Sequence[ColorInput], blend: str | bool = True, operator: str | bool = True, space: str | None = None, out_space: str | None = None -) -> Color: +) -> AnyColor: """Blend colors using the specified blend mode.""" + if not colors: # pragma: no cover + raise ValueError('At least one color is required for compositing.') + # We need to go ahead and grab the blender as we need to check what type of blender it is. blender = None # blend_modes.Blend | None if isinstance(blend, str): @@ -99,20 +99,12 @@ def compose( if out_space is None: out_space = space - if not isinstance(color.CS_MAP[space], RGBish): - raise ValueError("Can only compose in an RGBish color space, not {}".format(type(color.CS_MAP[space]))) - - if not backdrop: - return color - - if len(backdrop) > 1: - dest = backdrop[-1].convert(space) - for x in range(len(backdrop) - 2, -1, -1): - src = backdrop[x].convert(space) - dest = apply_compositing(src, dest, blender, operator) - else: - dest = backdrop[0].convert(space) + if not isinstance(color_cls.CS_MAP[space], RGBish): + raise ValueError(f"Can only compose in an RGBish color space, not {type(color_cls.CS_MAP[space])}") - src = color.convert(space) + dest = color_cls._handle_color_input(colors[-1]).convert(space).normalize(nans=False)[:] + for x in range(len(colors) - 2, -1, -1): + src = color_cls._handle_color_input(colors[x]).convert(space).normalize(nans=False)[:] + dest = apply_compositing(src, dest, color_cls.CS_MAP[space].channels, blender, operator) - return apply_compositing(src, dest, blender, operator).convert(out_space, in_place=True) + return color_cls(space, dest[:-1], dest[-1]).convert(out_space, in_place=True) diff --git a/lib/coloraide/compositing/blend_modes.py b/lib/coloraide/compositing/blend_modes.py index d142934..f9cf90e 100644 --- a/lib/coloraide/compositing/blend_modes.py +++ b/lib/coloraide/compositing/blend_modes.py @@ -97,10 +97,10 @@ def apply(self, cb: Vector, cs: Vector) -> Vector: # pragma: no cover raise NotImplementedError('apply is not implemented') - def blend(self, coords_backgrond: Vector, coords_source: Vector) -> Vector: + def blend(self, coords1: Vector, coords2: Vector) -> Vector: """Apply blending logic.""" - return self.apply(coords_backgrond, coords_source) + return self.apply(coords1, coords2) class BlendNormal(SeperableBlend): @@ -306,5 +306,5 @@ def get_blender(blend: str) -> Blend: blender = SUPPORTED.get(blend) if not blender: - raise ValueError("'{}' is not a recognized blend mode".format(blend)) + raise ValueError(f"'{blend}' is not a recognized blend mode") return blender diff --git a/lib/coloraide/compositing/porter_duff.py b/lib/coloraide/compositing/porter_duff.py index f32500f..b8a55ec 100644 --- a/lib/coloraide/compositing/porter_duff.py +++ b/lib/coloraide/compositing/porter_duff.py @@ -239,5 +239,5 @@ def compositor(name: str) -> type[PorterDuff]: composite = SUPPORTED.get(name) if not composite: - raise ValueError("'{}' compositing is not supported".format(name)) + raise ValueError(f"'{name}' compositing is not supported") return composite diff --git a/lib/coloraide/contrast/__init__.py b/lib/coloraide/contrast/__init__.py index efc4157..11921ef 100644 --- a/lib/coloraide/contrast/__init__.py +++ b/lib/coloraide/contrast/__init__.py @@ -1,11 +1,8 @@ """Contrast.""" from __future__ import annotations from abc import ABCMeta, abstractmethod -from ..types import Plugin -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from ..types import Plugin, AnyColor +from typing import Any class ColorContrast(Plugin, metaclass=ABCMeta): @@ -14,11 +11,11 @@ class ColorContrast(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def contrast(self, color1: Color, color2: Color, **kwargs: Any) -> float: + def contrast(self, color1: AnyColor, color2: AnyColor, **kwargs: Any) -> float: """Get the contrast of the two provided colors.""" -def contrast(name: str | None, color1: Color, color2: Color, **kwargs: Any) -> float: +def contrast(name: str | None, color1: AnyColor, color2: AnyColor, **kwargs: Any) -> float: """Get the appropriate contrast plugin.""" if name is None: @@ -26,6 +23,6 @@ def contrast(name: str | None, color1: Color, color2: Color, **kwargs: Any) -> f method = color1.CONTRAST_MAP.get(name) if not method: - raise ValueError("'{}' contrast method is not supported".format(name)) + raise ValueError(f"'{name}' contrast method is not supported") return method.contrast(color1, color2, **kwargs) diff --git a/lib/coloraide/contrast/lstar.py b/lib/coloraide/contrast/lstar.py index 74c3ecd..cf3e395 100644 --- a/lib/coloraide/contrast/lstar.py +++ b/lib/coloraide/contrast/lstar.py @@ -7,10 +7,8 @@ """ from __future__ import annotations from ..contrast import ColorContrast -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from ..types import AnyColor +from typing import Any class LstarContrast(ColorContrast): @@ -18,7 +16,7 @@ class LstarContrast(ColorContrast): NAME = "lstar" - def contrast(self, color1: Color, color2: Color, **kwargs: Any) -> float: + def contrast(self, color1: AnyColor, color2: AnyColor, **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 e8a9a38..646f34a 100644 --- a/lib/coloraide/contrast/wcag21.py +++ b/lib/coloraide/contrast/wcag21.py @@ -5,18 +5,15 @@ """ from __future__ import annotations from ..contrast import ColorContrast -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color - +from ..types import AnyColor +from typing import Any class WCAG21Contrast(ColorContrast): """WCAG 2.1 contrast ratio.""" NAME = "wcag21" - def contrast(self, color1: Color, color2: Color, **kwargs: Any) -> float: + def contrast(self, color1: AnyColor, color2: AnyColor, **kwargs: Any) -> float: """Contrast.""" lum1 = max(0, color1.luminance()) diff --git a/lib/coloraide/convert.py b/lib/coloraide/convert.py index 977dcdd..d09646d 100644 --- a/lib/coloraide/convert.py +++ b/lib/coloraide/convert.py @@ -4,8 +4,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from .color import Color from .spaces import Space + from .color import Color # XYZ is the absolute base, meaning that XYZ is the final base in any conversion chain. # This is a design expectation regardless of whether someone assigns a different base to XYZ or not. @@ -27,7 +27,7 @@ def calc_path_to_xyz( obj = color.CS_MAP.get(space) if obj is None: - raise ValueError("'{}' is not a valid color space".format(space)) + raise ValueError(f"'{space}' is not a valid color space") # Create a worse case conversion chain from XYZ to the target temp = obj @@ -83,7 +83,7 @@ def get_convert_chain( base_space = color.CS_MAP[current.BASE] # Do we need to chromatically adapt towards XYZ D65? - adapt = base_space.NAME == ABSOLUTE_BASE + adapt = base_space.NAME == ABSOLUTE_BASE and current.WHITE != base_space.WHITE # Add conversion chain entry chain.append((current, base_space, 0, adapt)) @@ -105,14 +105,14 @@ def get_convert_chain( # Start in the chain where the current color resides start = from_color_index[current.NAME] - 1 - # Do we need to chromatically adapt away from XYZ D65? - adapt = current.NAME == ABSOLUTE_BASE - - # Moving away from XYZ D65, convert towards are desired target + # Moving away from XYZ D65, convert towards our desired target for index in range(start, -1, -1): base_space = current current = from_color[index] + # Do we need to chromatically adapt away from XYZ D65? + adapt = base_space.NAME == ABSOLUTE_BASE and current.WHITE != base_space.WHITE + # Add the conversion chain entry chain.append((base_space, current, 1, adapt)) diff --git a/lib/coloraide/css/parse.py b/lib/coloraide/css/parse.py index 4b5a05c..a592cde 100644 --- a/lib/coloraide/css/parse.py +++ b/lib/coloraide/css/parse.py @@ -30,7 +30,7 @@ RE_FUNC_END = re.compile(r'\s*(\))') RE_COMMA = re.compile(r'\s*(,)\s*') RE_SLASH = re.compile(r'\s*(/)\s*') -RE_CSS_FUNC = re.compile(r'\b(color|rgba?|hsla?|hwb|(?:ok)?lab|(?:ok)?lch)\b') +RE_CSS_FUNC = re.compile(r'\b(color|rgba?|hsla?|hwb|(?:ok)?lab|(?:ok)?lch|jzazbz|jzczhz|ictcp)\b') def norm_float(string: str) -> float: @@ -57,7 +57,7 @@ def norm_percent_channel(string: str, scale: float = 100, offset: float = 0.0) - return (value * scale * 0.01) - offset if scale != 100 else value else: # pragma: no cover # Should only occur internally if we are doing something wrong. - raise ValueError("Unexpected value '{}'".format(string)) + raise ValueError(f"Unexpected value '{string}'") def norm_color_channel(string: str, scale: float = 1, offset: float = 0.0) -> float: @@ -426,13 +426,13 @@ def tokenize_css(css: str, start: int = 0) -> dict[str, Any]: if not validate_cylindrical_srgb(tokens): return {} - elif func_name in ('lab', 'oklab'): + elif func_name in ('lab', 'oklab', 'jzazbz', 'ictcp'): tokens['id'] = '--' + func_name if not validate_lab(tokens): return {} - elif func_name in ('oklch', 'lch'): + elif func_name in ('oklch', 'lch', 'jzczhz'): tokens['id'] = '--' + func_name if not validate_lch(tokens): diff --git a/lib/coloraide/css/serialize.py b/lib/coloraide/css/serialize.py index e003218..cff34ec 100644 --- a/lib/coloraide/css/serialize.py +++ b/lib/coloraide/css/serialize.py @@ -7,9 +7,9 @@ from .color_names import to_name from ..channels import FLG_ANGLE from ..types import Vector -from typing import TYPE_CHECKING, Sequence, Any +from typing import Sequence, Any, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color RE_COMPRESS = re.compile(r'(?i)^#([a-f0-9])\1([a-f0-9])\2([a-f0-9])\3(?:([a-f0-9])\4)?$') @@ -21,7 +21,7 @@ def named_color( - obj: 'Color', + obj: Color, alpha: bool | None, fit: str | bool | dict[str, Any] ) -> str | None: @@ -34,10 +34,11 @@ def named_color( def color_function( - obj: 'Color', + obj: Color, func: str | None, alpha: bool | None, - precision: int, + precision: int | Sequence[int], + rounding: str, fit: str | bool | dict[str, Any], none: bool, percent: bool | Sequence[bool], @@ -54,7 +55,7 @@ def color_function( # `color` should include the color space serialized name. if func is None: - string = ['color({} '.format(obj._space._serialize()[0])] + string = [f'color({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)] @@ -69,10 +70,10 @@ def color_function( # - 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) + percent = obj._space._percents if percent else [] + + # Ensure precision list is filled + is_precision_list = not isinstance(precision, int) # Iterate the coordinates formatting them by scaling the values, formatting for percent, etc. for idx, value in enumerate(coords): @@ -83,7 +84,7 @@ def color_function( string.append(COMMA if legacy else SPACE) channel = channels[idx] - if not (channel.flags & FLG_ANGLE) and plist and plist[idx]: + if not (channel.flags & FLG_ANGLE) and percent and util.get_index(percent, idx, False): span, offset = channel.span, channel.offset else: span = offset = 0.0 @@ -93,7 +94,8 @@ def color_function( string.append( util.fmt_float( value, - precision, + util.get_index(precision, idx, obj.PRECISION) if is_precision_list else precision, # type: ignore[arg-type] + rounding, span, offset ) @@ -104,7 +106,7 @@ def color_function( def get_coords( - obj: 'Color', + obj: Color, fit: bool | str | dict[str, Any], none: bool, legacy: bool @@ -124,7 +126,7 @@ def get_coords( def get_alpha( - obj: 'Color', + obj: Color, alpha: bool | None, none: bool, legacy: bool @@ -137,7 +139,7 @@ def get_alpha( def hexadecimal( - obj: 'Color', + obj: Color, alpha: bool | None = None, fit: str | bool | dict[str, Any] = True, upper: bool = False, @@ -145,7 +147,7 @@ def hexadecimal( ) -> str: """Get the hex `RGB` value.""" - coords = get_coords(obj, fit, False, False) + coords = get_coords(obj, fit if fit else True, False, False) a = get_alpha(obj, alpha, False, False) if a is not None: @@ -173,11 +175,12 @@ def hexadecimal( def serialize_css( - obj: 'Color', + obj: Color, func: str = '', color: bool = False, alpha: bool | None = None, - precision: int | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, percent: bool | Sequence[bool] = False, @@ -193,9 +196,12 @@ def serialize_css( if precision is None: precision = obj.PRECISION + if rounding is None: + rounding = obj.ROUNDING + # Color format if color: - return color_function(obj, None, alpha, precision, fit, none, percent, False, 1.0) + return color_function(obj, None, alpha, precision, rounding, fit, none, percent, False, 1.0) # CSS color names if name: @@ -209,6 +215,6 @@ def serialize_css( # Normal CSS named function format if func: - return color_function(obj, func, alpha, precision, fit, none, percent, legacy, scale) + return color_function(obj, func, alpha, precision, rounding, 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 12b04d9..f5f6b62 100644 --- a/lib/coloraide/deprecate.py +++ b/lib/coloraide/deprecate.py @@ -20,7 +20,7 @@ def _wrapper(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) def _deprecated_func(*args: Any, **kwargs: Any) -> Any: warnings.warn( - "'{}' is deprecated. {}".format(func.__name__, message), + f"'{func.__name__}' is deprecated. {message}", category=DeprecationWarning, stacklevel=stacklevel ) diff --git a/lib/coloraide/distance/__init__.py b/lib/coloraide/distance/__init__.py index 89f4da0..d1ec841 100644 --- a/lib/coloraide/distance/__init__.py +++ b/lib/coloraide/distance/__init__.py @@ -3,14 +3,11 @@ import math from .. import algebra as alg from abc import ABCMeta, abstractmethod -from ..types import ColorInput, Plugin -from typing import TYPE_CHECKING, Any, Sequence +from ..types import ColorInput, Plugin, AnyColor +from typing import Any, Sequence -if TYPE_CHECKING: # pragma: no cover - from ..color import Color - -def closest(color: Color, colors: Sequence[ColorInput], method: str | None = None, **kwargs: Any) -> Color: +def closest(color: AnyColor, colors: Sequence[ColorInput], method: str | None = None, **kwargs: Any) -> AnyColor: """Get the closest color.""" if method is None: @@ -18,7 +15,7 @@ def closest(color: Color, colors: Sequence[ColorInput], method: str | None = Non algorithm = color.DE_MAP.get(method) if not algorithm: - raise ValueError("'{}' is not currently a supported distancing algorithm.".format(method)) + raise ValueError(f"'{method}' is not currently a supported distancing algorithm.") lowest = math.inf closest = None @@ -35,7 +32,7 @@ def closest(color: Color, colors: Sequence[ColorInput], method: str | None = Non return closest -def distance_euclidean(color: Color, sample: Color, space: str = "lab-d65") -> float: +def distance_euclidean(color: AnyColor, sample: AnyColor, space: str = "lab-d65") -> float: """ Euclidean distance. @@ -68,5 +65,5 @@ class DeltaE(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: + def distance(self, color: AnyColor, sample: AnyColor, **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 71c3edd..da43626 100644 --- a/lib/coloraide/distance/delta_e_2000.py +++ b/lib/coloraide/distance/delta_e_2000.py @@ -3,10 +3,8 @@ import math from ..distance import DeltaE from ..spaces.lab import CIELab -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from ..types import AnyColor +from typing import Any class DE2000(DeltaE): @@ -31,8 +29,8 @@ def __init__( def distance( self, - color: Color, - sample: Color, + color: AnyColor, + sample: AnyColor, kl: float | None = None, kc: float | None = None, kh: float | None = None, diff --git a/lib/coloraide/distance/delta_e_76.py b/lib/coloraide/distance/delta_e_76.py index 140559f..29250a0 100644 --- a/lib/coloraide/distance/delta_e_76.py +++ b/lib/coloraide/distance/delta_e_76.py @@ -1,10 +1,9 @@ """Delta E 76.""" from __future__ import annotations from ..distance import DeltaE, distance_euclidean -from typing import TYPE_CHECKING, Any +from ..types import AnyColor +from typing import Any from ..spaces.lab import CIELab -if TYPE_CHECKING: # pragma: no cover - from ..color import Color class DE76(DeltaE): @@ -19,8 +18,8 @@ def __init__(self, space: str = 'lab-d65'): def distance( self, - color: Color, - sample: Color, + color: AnyColor, + sample: AnyColor, space: str | None = None, **kwargs: Any ) -> float: diff --git a/lib/coloraide/distance/delta_e_94.py b/lib/coloraide/distance/delta_e_94.py index 8a71e5a..c9760cf 100644 --- a/lib/coloraide/distance/delta_e_94.py +++ b/lib/coloraide/distance/delta_e_94.py @@ -1,12 +1,10 @@ """Delta E 94.""" from __future__ import annotations +import math from ..distance import DeltaE from ..spaces.lab import CIELab -import math -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from .. types import AnyColor +from typing import Any class DE94(DeltaE): @@ -30,8 +28,8 @@ def __init__( def distance( self, - color: Color, - sample: Color, + color: AnyColor, + sample: AnyColor, kl: float | None = None, k1: float | None = None, k2: float | None = None, diff --git a/lib/coloraide/distance/delta_e_99o.py b/lib/coloraide/distance/delta_e_99o.py index 18a9a5a..83d349b 100644 --- a/lib/coloraide/distance/delta_e_99o.py +++ b/lib/coloraide/distance/delta_e_99o.py @@ -5,10 +5,8 @@ """ 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 +from ..types import AnyColor +from typing import Any class DE99o(DeltaE): @@ -16,7 +14,7 @@ class DE99o(DeltaE): NAME = '99o' - def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: + def distance(self, color: AnyColor, sample: AnyColor, **kwargs: Any) -> float: """Get delta E 99o.""" return distance_euclidean(color, sample, space='din99o') diff --git a/lib/coloraide/distance/delta_e_cam02.py b/lib/coloraide/distance/delta_e_cam02.py new file mode 100644 index 0000000..b44b7c7 --- /dev/null +++ b/lib/coloraide/distance/delta_e_cam02.py @@ -0,0 +1,49 @@ +""" +Delta E CAM02. + +https://en.wikipedia.org/wiki/CIECAM02 +https://www.researchgate.net/publication/221501922_The_CIECAM02_color_appearance_model + +The articles don't specifically cover the distancing algorithm, but CAM02 uses the same +UCS code and applies the same distancing algorithm as CAM16. + +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 ..spaces.cam02_ucs import CAM02UCS +from ..spaces.cam16_ucs import COEFFICENTS +from ..types import AnyColor +from typing import Any + + +class DECAM02(DeltaE): + """Delta E CAM02 class.""" + + NAME = "cam02" + + def distance( + self, + color: AnyColor, + sample: AnyColor, + space: str = "cam02-ucs", + **kwargs: Any + ) -> float: + """Delta E CAM02 color distance formula.""" + + # Normal approach to specifying CAM02 target space + cs = color.CS_MAP[space] + if not isinstance(cs, CAM02UCS): + raise ValueError("Distance color space must be derived from CAM02UCS.") + model = cs.MODEL + kl = COEFFICENTS[model][0] + + j1, a1, b1 = color.convert(space).coords(nans=False) + j2, a2, b2 = sample.convert(space).coords(nans=False) + + dj = j1 - j2 + da = a1 - a2 + db = b1 - b2 + + return math.sqrt((dj / kl) ** 2 + da ** 2 + db ** 2) diff --git a/lib/coloraide/distance/delta_e_cam16.py b/lib/coloraide/distance/delta_e_cam16.py index a80f046..1f24901 100644 --- a/lib/coloraide/distance/delta_e_cam16.py +++ b/lib/coloraide/distance/delta_e_cam16.py @@ -6,17 +6,9 @@ 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, 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" -) +from ..types import AnyColor +from typing import Any class DECAM16(DeltaE): @@ -26,27 +18,19 @@ class DECAM16(DeltaE): def distance( self, - color: Color, - sample: Color, + color: AnyColor, + sample: AnyColor, 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] + cs = color.CS_MAP[space] + if not isinstance(cs, CAM16UCS): + raise ValueError("Distance color space must be derived from CAM16UCS.") + model = cs.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 c6dfe21..26fbc35 100644 --- a/lib/coloraide/distance/delta_e_cmc.py +++ b/lib/coloraide/distance/delta_e_cmc.py @@ -3,10 +3,8 @@ from ..distance import DeltaE from ..spaces.lab import CIELab import math -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from ..types import AnyColor +from typing import Any class DECMC(DeltaE): @@ -28,8 +26,8 @@ def __init__( def distance( self, - color: Color, - sample: Color, + color: AnyColor, + sample: AnyColor, l: float | None = None, c: float | None = None, space: str | None = None, diff --git a/lib/coloraide/distance/delta_e_hct.py b/lib/coloraide/distance/delta_e_hct.py index 4ce73c0..611f484 100644 --- a/lib/coloraide/distance/delta_e_hct.py +++ b/lib/coloraide/distance/delta_e_hct.py @@ -3,10 +3,10 @@ import math from ..distance import DeltaE from ..spaces.cam16_ucs import COEFFICENTS -from ..types import VectorLike +from ..types import VectorLike, AnyColor from typing import Any, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color COEFF2 = COEFFICENTS['ucs'][2] @@ -34,7 +34,7 @@ class DEHCT(DeltaE): NAME = "hct" - def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: + def distance(self, color: AnyColor, sample: AnyColor, **kwargs: Any) -> float: """Delta E HCT color distance formula.""" t1, a1, b1 = convert_ucs_ab( diff --git a/lib/coloraide/distance/delta_e_hyab.py b/lib/coloraide/distance/delta_e_hyab.py index 551b0c0..933202f 100644 --- a/lib/coloraide/distance/delta_e_hyab.py +++ b/lib/coloraide/distance/delta_e_hyab.py @@ -3,10 +3,8 @@ from ..distance import DeltaE import math from ..spaces import Labish -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from ..types import AnyColor +from typing import Any class DEHyAB(DeltaE): @@ -19,7 +17,7 @@ def __init__(self, space: str = "lab-d65") -> None: self.space = space - def distance(self, color: Color, sample: Color, space: str | None = None, **kwargs: Any) -> float: + def distance(self, color: AnyColor, sample: AnyColor, space: str | None = None, **kwargs: Any) -> float: """ HyAB distance for Lab-ish spaces. @@ -33,7 +31,7 @@ def distance(self, color: Color, sample: Color, space: str | None = None, **kwar sample = sample.convert(space) if not isinstance(color._space, Labish): - raise ValueError("The space '{}' is not a 'lab-ish' color space and cannot use HyAB".format(space)) + raise ValueError(f"The space '{space}' is not a 'lab-ish' color space and cannot use HyAB") names = color._space.names() l1, a1, b1 = color.get(names, nans=False) diff --git a/lib/coloraide/distance/delta_e_itp.py b/lib/coloraide/distance/delta_e_itp.py index 26c3f2b..f12097d 100644 --- a/lib/coloraide/distance/delta_e_itp.py +++ b/lib/coloraide/distance/delta_e_itp.py @@ -4,12 +4,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 - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from ..distance import DeltaE +from ..types import AnyColor +from typing import Any class DEITP(DeltaE): @@ -22,7 +20,7 @@ def __init__(self, scalar: float = 720) -> None: self.scalar = scalar - def distance(self, color: Color, sample: Color, scalar: float | None = None, **kwargs: Any) -> float: + def distance(self, color: AnyColor, sample: AnyColor, 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 7166400..6cdd3e2 100644 --- a/lib/coloraide/distance/delta_e_ok.py +++ b/lib/coloraide/distance/delta_e_ok.py @@ -1,10 +1,8 @@ """Delta E OK.""" 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 +from ..types import AnyColor +from typing import Any class DEOK(DeltaE): @@ -17,7 +15,7 @@ def __init__(self, scalar: float = 1) -> None: self.scalar = scalar - def distance(self, color: Color, sample: Color, scalar: float | None = None, **kwargs: Any) -> float: + def distance(self, color: AnyColor, sample: AnyColor, scalar: float | None = None, **kwargs: Any) -> float: """ Delta E OK color distance formula. diff --git a/lib/coloraide/distance/delta_e_z.py b/lib/coloraide/distance/delta_e_z.py index 7f22404..e6ac119 100644 --- a/lib/coloraide/distance/delta_e_z.py +++ b/lib/coloraide/distance/delta_e_z.py @@ -4,12 +4,10 @@ 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 - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from ..distance import DeltaE +from ..types import AnyColor +from typing import Any class DEZ(DeltaE): @@ -17,7 +15,7 @@ class DEZ(DeltaE): NAME = "jz" - def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: + def distance(self, color: AnyColor, sample: AnyColor, **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 11c4775..5d32ff9 100644 --- a/lib/coloraide/easing.py +++ b/lib/coloraide/easing.py @@ -40,83 +40,47 @@ """ from __future__ import annotations import functools -import math +from . import algebra as alg from typing import Callable -EPSILON = 1e-6 -MAX_ITER = 8 - -def _bezier(t: float, a: float, b: float, c: float) -> float: +def _bezier(a: float, b: float, c: float, y: float = 0.0) -> Callable[[float], float]: """ - Calculate the bezier point. + Calculate the Bezier point. We know that P0 and P3 are always (0, 0) and (1, 1) respectively. Knowing this we can simplify the equation by precalculating them in. """ - return a * t ** 3 + b * t ** 2 + c * t - - -def _bezier_derivative(t: float, a: float, b: float, c: float) -> float: - """Derivative of curve.""" - - return 3 * a * t ** 2 + 2 * b * t + c + return lambda x: a * x ** 3 + b * x ** 2 + c * x - y -def _solve_bezier(target: float, a: float, b: float, c: float) -> float: +def _solve_bezier( + target: float, + a: float, + b: float, + c: float +) -> float: """ - Solve curve to find a `t` that satisfies our desired `x`. + Solve curve to find a `t` that satisfies our desired `x` (target). - Try a few rounds of Newton's method which is generally faster. If we fail, - resort to binary search. + The `target` is expected to be within the range of 0 - 1, this will yield a `t` within that same range. """ - # Try Newtons method to see if we can find a suitable value - x = 0.0 - t = 0.5 - last = math.nan - for _ in range(MAX_ITER): - # See how close we are to the desired `x` - x = _bezier(t, a, b, c) - target - - # Get the derivative, but bail if it is too small, - # we will just keep looping otherwise. - dx = _bezier_derivative(t, a, b, c) - - # Derivative is zero, we can't continue - if dx == 0: # pragma: no cover - break - - # Calculate new time and try again - t -= (x / dx) - - # We converged on an solution - if t == last: # pragma: no cover - return t - - last = t - - # We didn't fully converge but we are close, closer than our bisect epsilon - if abs(_bezier(t, a, b, c) - target) < EPSILON: - return t - - # We couldn't achieve our desired accuracy with Newton, - # so bisect at lower accuracy until we arrive at a suitable value - low, high = 0.0, 1.0 - t = target - while abs(high - low) > EPSILON: - x = _bezier(t, a, b, c) - if abs(x - target) < EPSILON: - return t - if x > target: - high = t - else: - low = t - t = (high + low) * 0.5 - - # Just return whatever we got closest to - return t # pragma: no cover + # These Bezier curves are designed such that beyond the range 0 - 1 the response is linear, + # so it is assumed that any values at or exceeding this range `x == t` + if target <= 0.0 or target >= 1.0: + return target + + roots = alg.solve_poly([a, b, c, -target]) + # Find value within range + for r in roots: + # Solution is well within range + if 0 <= r <= 1: + return r + + # We should never hit this in normal situations + raise ValueError(f'Could not find a solution for {a}t ** 3 + {b}t ** 2 + {c} = {target}') def _extrapolate(t: float, p1: tuple[float, float], p2: tuple[float, float]) -> float: @@ -173,21 +137,15 @@ def _calc_bezier( `t` that satisfies the `x` so that we can find the `y`. """ - # We'll likely not get a nice round 0 or 1 using the methods below, - # but we know 0 and 1 should yield 0 and 1, so shortcut out and - # same some calculations. - if target in (0, 1): - return target - - # Extrapolate per the spec - if target > 1 or target < 0: - return _extrapolate(target, p1, p2) - # Solve for `t` in relation to `x` t = _solve_bezier(target, a[0], b[0], c[0]) + # Extrapolate for `y` per the spec + if t > 1 or t < 0: + return _extrapolate(t, p1, p2) + # Use the found `t` to locate the `y` - y = _bezier(t, a[1], b[1], c[1]) + y = _bezier(a[1], b[1], c[1])(t) return y diff --git a/lib/coloraide/everything.py b/lib/coloraide/everything.py index 4d45250..e36c6c1 100644 --- a/lib/coloraide/everything.py +++ b/lib/coloraide/everything.py @@ -9,6 +9,8 @@ from .spaces.hpluv import HPLuv from .spaces.okhsl import Okhsl from .spaces.okhsv import Okhsv +from .spaces.oklrab import Oklrab +from .spaces.oklrch import OkLrCh from .spaces.hsi import HSI from .spaces.ipt import IPT from .spaces.igpgtg import IgPgTg @@ -24,16 +26,22 @@ from .spaces.acescg import ACEScg from .spaces.acescc import ACEScc from .spaces.acescct import ACEScct -from .spaces.cam16_jmh import CAM16JMh +from .spaces.cam02 import CAM02JMh +from .spaces.cam02_ucs import CAM02UCS, CAM02LCD, CAM02SCD +from .spaces.cam16 import CAM16JMh from .spaces.cam16_ucs import CAM16UCS, CAM16LCD, CAM16SCD -from .spaces.zcam_jmh import ZCAMJMh +from .spaces.hellwig import HellwigJMh, HellwigHKJMh +from .spaces.zcam import ZCAMJMh from .spaces.hct import HCT from .spaces.ucs import UCS from .spaces.rec709 import Rec709 +from .spaces.rec709_oetf import Rec709OETF from .spaces.ryb import RYB, RYBBiased from .spaces.cubehelix import Cubehelix +from .spaces.rec2020_oetf import Rec2020OETF from .distance.delta_e_99o import DE99o from .distance.delta_e_cam16 import DECAM16 +from .distance.delta_e_cam02 import DECAM02 from .distance.delta_e_hct import DEHCT from .gamut.fit_hct_chroma import HCTChroma from .interpolate.catmull_rom import CatmullRom @@ -54,12 +62,15 @@ class ColorAll(Base): [ # Spaces Rec709(), + Rec709OETF(), DIN99o(), LCh99o(), Luv(), LChuv(), Okhsl(), Okhsv(), + Oklrab(), + OkLrCh(), HSLuv(), HPLuv(), HSI(), @@ -77,20 +88,28 @@ class ColorAll(Base): ACEScg(), ACEScc(), ACEScct(), + CAM02JMh(), + CAM02UCS(), + CAM02LCD(), + CAM02SCD(), CAM16JMh(), CAM16UCS(), CAM16SCD(), CAM16LCD(), + HellwigJMh(), + HellwigHKJMh(), HCT(), UCS(), RYB(), RYBBiased(), Cubehelix(), ZCAMJMh(), + Rec2020OETF(), # Delta E DE99o(), DECAM16(), + DECAM02(), DEHCT(), # Gamut Mapping diff --git a/lib/coloraide/filters/__init__.py b/lib/coloraide/filters/__init__.py index 48397f1..a481040 100644 --- a/lib/coloraide/filters/__init__.py +++ b/lib/coloraide/filters/__init__.py @@ -1,10 +1,10 @@ """Provides a plugin system for filtering colors.""" from __future__ import annotations from abc import ABCMeta, abstractmethod -from ..types import Plugin +from ..types import Plugin, AnyColor from typing import Any, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color @@ -21,26 +21,26 @@ def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # def filters( - color: Color, + color: AnyColor, name: str, amount: float | None = None, space: str | None = None, out_space: str | None = None, in_place: bool = False, **kwargs: Any -) -> Color: +) -> AnyColor: """Filter.""" f = color.FILTER_MAP.get(name) if not f: - raise ValueError("'{}' filter is not supported".format(name)) + raise ValueError(f"'{name}' filter is not supported") if space is None: space = f.DEFAULT_SPACE if space not in f.ALLOWED_SPACES: raise ValueError( - "The '{}' only supports filtering in the {} spaces, not '{}'".format(name, str(f.ALLOWED_SPACES), space) + f"The '{name}' only supports filtering in the {f.ALLOWED_SPACES!s} spaces, not '{space}'" ) if out_space is None: diff --git a/lib/coloraide/filters/cvd.py b/lib/coloraide/filters/cvd.py index abc8a55..0bc1dce 100644 --- a/lib/coloraide/filters/cvd.py +++ b/lib/coloraide/filters/cvd.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Color vision deficiency.""" from __future__ import annotations from .. import algebra as alg @@ -6,7 +5,7 @@ from ..types import Vector, Matrix from typing import Any, Callable, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color LRGB_TO_LMS = [ @@ -137,11 +136,11 @@ def brettel(color: Color, severity: float, wings: tuple[Matrix, Matrix, Vector]) w1, w2, sep = wings # Convert to LMS - lms_c = alg.matmul(LRGB_TO_LMS, color[:-1], dims=alg.D2_D1) + lms_c = alg.matmul_x3(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.matmul(w2 if alg.matmul(lms_c, sep) > 0 else w1, lms_c, dims=alg.D2_D1) + coords = alg.matmul_x3(w2 if alg.matmul_x3(lms_c, sep, dims=alg.D1) > 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)] @@ -165,7 +164,7 @@ def vienot(color: Color, severity: float, transform: Matrix) -> None: then we interpolate against the original color. """ - coords = alg.matmul(transform, color[:-1], dims=alg.D2_D1) + coords = alg.matmul_x3(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: @@ -188,7 +187,7 @@ def machado(color: Color, severity: float, matrices: dict[int, Matrix]) -> None: # Filter the color according to the severity m1 = matrices[severity1] - coords = alg.matmul(m1, color[:-1], dims=alg.D2_D1) + coords = alg.matmul_x3(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 @@ -205,7 +204,7 @@ def machado(color: Color, severity: float, matrices: dict[int, Matrix]) -> None: # 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.matmul(m2, color[:-1], dims=alg.D2_D1) + coords2 = alg.matmul_x3(m2, color[:-1], dims=alg.D2_D1) coords = [alg.lerp(c1, c2, weight) for c1, c2 in zip(coords, coords2)] # Return the altered color @@ -254,7 +253,7 @@ def select_filter(self, method: str) -> Callable[..., None]: elif method == 'machado': return self.machado else: - raise ValueError("Unrecognized CVD filter method '{}'".format(method)) + raise ValueError(f"Unrecognized CVD filter method '{method}'") def get_best_filter(self, method: str | None, max_severity: bool) -> Callable[..., None]: """Get the best filter based on the situation.""" diff --git a/lib/coloraide/filters/w3c_filter_effects.py b/lib/coloraide/filters/w3c_filter_effects.py index 8adc4de..022b950 100644 --- a/lib/coloraide/filters/w3c_filter_effects.py +++ b/lib/coloraide/filters/w3c_filter_effects.py @@ -5,7 +5,7 @@ from .. import algebra as alg from typing import Any, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color @@ -36,7 +36,7 @@ def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # [0.272 - 0.272 * amount, 0.534 - 0.534 * amount, 0.131 + 0.869 * amount] ] - color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul_x3(m, color[:-1], dims=alg.D2_D1) class Grayscale(Filter): @@ -56,7 +56,7 @@ def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # [0.2126 - 0.2126 * amount, 0.7152 - 0.7152 * amount, 0.0722 + 0.9278 * amount] ] - color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul_x3(m, color[:-1], dims=alg.D2_D1) class Saturate(Filter): @@ -76,7 +76,7 @@ def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # [0.213 - 0.213 * amount, 0.715 - 0.715 * amount, 0.072 + 0.928 * amount] ] - color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul_x3(m, color[:-1], dims=alg.D2_D1) class Invert(Filter): @@ -153,4 +153,4 @@ def filter(self, color: Color, amount: float | None, **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.matmul(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul_x3(m, color[:-1], dims=alg.D2_D1) diff --git a/lib/coloraide/gamut/__init__.py b/lib/coloraide/gamut/__init__.py index 270766c..85bee9d 100644 --- a/lib/coloraide/gamut/__init__.py +++ b/lib/coloraide/gamut/__init__.py @@ -4,10 +4,10 @@ from ..channels import FLG_ANGLE from abc import ABCMeta, abstractmethod from ..types import Plugin -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING from . import pointer -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color __all__ = ('clip_channels', 'verify', 'Fit', 'pointer') @@ -21,7 +21,7 @@ def clip_channels(color: Color, nans: bool = True) -> bool: cs = color._space for i, value in enumerate(cs.normalize(color[:-1])): - chan = cs.CHANNELS[i] + chan = cs.channels[i] # Ignore angles, undefined, or unbounded channels if not chan.bound or math.isnan(value) or chan.flags & FLG_ANGLE: @@ -47,7 +47,7 @@ def verify(color: Color, tolerance: float) -> bool: cs = color._space for i, value in enumerate(cs.normalize(color[:-1])): - chan = cs.CHANNELS[i] + chan = cs.channels[i] # Ignore undefined channels, angles which wrap, and unbounded channels if not chan.bound or math.isnan(value) or chan.flags & FLG_ANGLE: diff --git a/lib/coloraide/gamut/fit_hct_chroma.py b/lib/coloraide/gamut/fit_hct_chroma.py index 196531b..8584e32 100644 --- a/lib/coloraide/gamut/fit_hct_chroma.py +++ b/lib/coloraide/gamut/fit_hct_chroma.py @@ -1,18 +1,12 @@ """HCT gamut mapping.""" from __future__ import annotations -from ..gamut.fit_lch_chroma import LChChroma +from ..gamut.fit_minde_chroma import MINDEChroma -class HCTChroma(LChChroma): +class HCTChroma(MINDEChroma): """HCT chroma gamut mapping class.""" NAME = "hct-chroma" - - EPSILON = 0.01 - LIMIT = 2.0 - DE = "hct" - DE_OPTIONS = {} - SPACE = "hct" - MIN_LIGHTNESS = 0 - MAX_LIGHTNESS = 100 - MIN_CONVERGENCE = 0.0001 + JND = 2.0 + DE_OPTIONS = {"method": "hct"} + PSPACE = "hct" diff --git a/lib/coloraide/gamut/fit_lch_chroma.py b/lib/coloraide/gamut/fit_lch_chroma.py index e1a75f4..260c58c 100644 --- a/lib/coloraide/gamut/fit_lch_chroma.py +++ b/lib/coloraide/gamut/fit_lch_chroma.py @@ -1,112 +1,12 @@ -"""Fit by compressing chroma in LCh.""" +"""Fit by compressing chroma in OkLCh.""" 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 +from .fit_minde_chroma import MINDEChroma -if TYPE_CHECKING: # pragma: no cover - from ..color import Color -WHITE = util.xy_to_xyz(WHITES['2deg']['D65']) -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. - - Adjust chroma (using binary search). - This helps preserve the other attributes of the color. - Compress chroma until we are are right at the JND edge while still out of gamut. - Raise the lower chroma bound while we are in gamut or outside of gamut but still under the JND. - Lower the upper chroma bound anytime we are out of gamut and above the JND. - Too far under the JND we'll reduce chroma too aggressively. - - This is the same as the CSS algorithm as described here: https://www.w3.org/TR/css-color-4/#binsearch. - There are some small adjustments to handle HDR colors as the CSS algorithm assumes SDR color spaces. - Additionally, this uses LCh instead of OkLCh, but we also offer a derived version that uses OkLCh. - """ +class LChChroma(MINDEChroma): + """LCh chroma gamut mapping class.""" NAME = "lch-chroma" - - EPSILON = 0.01 - LIMIT = 2.0 - DE = "2000" - 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, space: str, *, jnd: float | None = None, **kwargs: Any) -> None: - """Gamut mapping via CIELCh chroma.""" - - 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 = 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 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: - clip_channels(color.update('xyz-d65', BLACK, mapcolor[-1])) - return - - # Set initial chroma boundaries - low = 0.0 - high = mapcolor[c] - clip_channels(gamutcolor._hotswap(mapcolor.convert(space, norm=False))) - - # Adjust chroma if we are not under the JND yet. - 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 - - # If high and low get too close to converging, - # we need to quit in order to prevent infinite looping. - while (high - low) > self.MIN_CONVERGENCE: - mapcolor[c] = (high + low) * 0.5 - - # Avoid doing expensive delta E checks if in gamut - if lower_in_gamut and mapcolor.in_gamut(space, tolerance=0): - low = mapcolor[c] - else: - 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 (jnd - de) < epsilon: - break - - # Our lower bound is now out of gamut, so all future searches are - # guaranteed to be out of gamut. Now we just want to focus on tuning - # chroma to get as close to the JND as possible. - if lower_in_gamut: - lower_in_gamut = False - low = mapcolor[c] - else: - # We are still outside the gamut and outside the JND - high = mapcolor[c] - color._hotswap(gamutcolor.convert(orig, norm=False)).normalize() + JND = 2.0 + DE_OPTIONS = {'method': '2000', 'space': 'lab-d65'} + PSPACE = "lch-d65" diff --git a/lib/coloraide/gamut/fit_lch_raytrace.py b/lib/coloraide/gamut/fit_lch_raytrace.py deleted file mode 100644 index 2866300..0000000 --- a/lib/coloraide/gamut/fit_lch_raytrace.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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_minde_chroma.py b/lib/coloraide/gamut/fit_minde_chroma.py new file mode 100644 index 0000000..d7669a6 --- /dev/null +++ b/lib/coloraide/gamut/fit_minde_chroma.py @@ -0,0 +1,151 @@ +"""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 .tools import adaptive_hue_independent +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: #pragma: no cover + from ..color import Color + +XYZ = 'xyz-d65' +WHITE = util.xy_to_xyz(WHITES['2deg']['D65']) +BLACK = [0.0, 0.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 (1 * 10.0 ** (alg.order(jnd) - 2)) + + +class MINDEChroma(Fit): + """ + Chroma reduction with MINDE. + + Adjust chroma (using binary search) which helps preserve perceptual hue and lightness. + Compress chroma until we are right at the JND edge while still out of gamut. + Raise the lower chroma bound while we are in gamut or outside of gamut but still under the JND. + Lower the upper chroma bound anytime we are out of gamut and above the JND. + Too far under the JND we'll reduce chroma too aggressively. + + This is the same as the CSS algorithm as described here: https://www.w3.org/TR/css-color-4/#binsearch. + There are some small adjustments to handle HDR colors as the CSS algorithm assumes SDR color spaces. + Additionally, this uses LCh instead of OkLCh, but we also offer a derived version that uses OkLCh. + """ + + NAME = "minde-chroma" + JND = 0.02 + DE_OPTIONS = {"method": "ok"} # type: dict[str, Any] + PSPACE = "oklch" + MIN_CONVERGENCE = 0.0001 + + def fit( + self, + color: Color, + space: str, + *, + pspace: str | None = None, + jnd: float | None = None, + de_options: dict[str, Any] | None = None, + adaptive: float = 0.0, + **kwargs: Any + ) -> None: + """Gamut mapping via CIELCh chroma.""" + + # Identify the perceptual space and determine if it is rectangular or polar + if pspace is None: + pspace = self.PSPACE + polar = color.CS_MAP[pspace].is_polar() + orig = color.space() + mapcolor = color.convert(pspace, norm=False) if orig != pspace else color.clone().normalize(nans=False) + gamutcolor = color.convert(space, norm=False) if orig != space else color.clone().normalize(nans=False) + if polar: + l, c, h = mapcolor._space.indexes() + else: + l, a, b = mapcolor._space.indexes() + lightness = mapcolor[l] + sdr = gamutcolor._space.DYNAMIC_RANGE == 'sdr' + if jnd is None: + jnd = self.JND + epsilon = calc_epsilon(jnd) + + if de_options is None: + de_options = self.DE_OPTIONS + + temp = color.new(XYZ, WHITE, mapcolor[-1]).convert(pspace, in_place=True) + max_light = temp[l] + + # 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 not adaptive: + if sdr and (lightness >= max_light or math.isclose(lightness, max_light, abs_tol=1e-6)): + clip_channels(color.update(temp)) + return + elif lightness <= temp.update(XYZ, BLACK, mapcolor[-1])[l]: + clip_channels(color.update(temp)) + return + + low = 0.0 + high, hue = (mapcolor[c], mapcolor[h]) if polar else alg.rect_to_polar(mapcolor[a], mapcolor[b]) + else: + chroma, hue = (mapcolor[c], mapcolor[h]) if polar else alg.rect_to_polar(mapcolor[a], mapcolor[b]) + light = mapcolor[l] + alight = adaptive_hue_independent(light / max_light, max(chroma, 0.0) / max_light, adaptive) * max_light + achroma = low = 0.0 + high = 1.0 + + clip_channels(gamutcolor) + + # Adjust chroma if we are not under the JND yet. + if not jnd or mapcolor.delta_e(gamutcolor, **de_options) >= jnd: + # Perform "in gamut" checks until we know our lower bound is no longer in gamut. + lower_in_gamut = True + + # If high and low get too close to converging, + # we need to quit in order to prevent infinite looping. + while (high - low) > self.MIN_CONVERGENCE: + value = (high + low) * 0.5 + if not adaptive: + if polar: + mapcolor[c] = value + else: + mapcolor[a], mapcolor[b] = alg.polar_to_rect(value, hue) + else: + mapcolor[l], c_ = alg.lerp(alight, light, value), alg.lerp(achroma, chroma, value) + if polar: + mapcolor[c] = c_ + else: + mapcolor[a], mapcolor[b] = alg.polar_to_rect(c_, hue) + + # Avoid doing expensive delta E checks if in gamut + temp = mapcolor.convert(space, norm=False) + if lower_in_gamut and temp.in_gamut(tolerance=0): + low = value + else: + gamutcolor = temp + clip_channels(gamutcolor) + # Bypass distance check if JND is 0 + de = mapcolor.delta_e(gamutcolor, **de_options) if jnd else 0.0 + 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 (jnd - de) < epsilon: + break + + # Our lower bound is now out of gamut, so all future searches are + # guaranteed to be out of gamut. Now we just want to focus on tuning + # chroma to get as close to the JND as possible. + if lower_in_gamut: + lower_in_gamut = False + low = value + else: + # We are still outside the gamut and outside the JND + high = value + + color.update(gamutcolor) diff --git a/lib/coloraide/gamut/fit_oklch_chroma.py b/lib/coloraide/gamut/fit_oklch_chroma.py index be3c0ec..5bd8023 100644 --- a/lib/coloraide/gamut/fit_oklch_chroma.py +++ b/lib/coloraide/gamut/fit_oklch_chroma.py @@ -1,16 +1,9 @@ """Fit by compressing chroma in OkLCh.""" from __future__ import annotations -from .fit_lch_chroma import LChChroma +from .fit_minde_chroma import MINDEChroma -class OkLChChroma(LChChroma): +class OkLChChroma(MINDEChroma): """OkLCh chroma gamut mapping class.""" NAME = "oklch-chroma" - - 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 deleted file mode 100644 index 8575af1..0000000 --- a/lib/coloraide/gamut/fit_oklch_raytrace.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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 index a556f25..7218515 100644 --- a/lib/coloraide/gamut/fit_raytrace.py +++ b/lib/coloraide/gamut/fit_raytrace.py @@ -5,22 +5,88 @@ """ from __future__ import annotations import math +from functools import lru_cache +from .. import util from .. import algebra as alg from ..gamut import Fit -from ..spaces import Space, RGBish, HSLish, HSVish, HWBish, Labish +from ..cat import WHITES +from ..spaces import Prism, Luminant, Space, HSLish, HSVish, HWBish 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.hwb import hwb_to_hsv, hsv_to_hwb from ..spaces.srgb_linear import sRGBLinear -from ..deprecate import warn_deprecated +from .tools import adaptive_hue_independent from ..types import Vector, VectorLike -from typing import TYPE_CHECKING, Callable, Any # noqa: F401 +from typing import Callable, Any, TYPE_CHECKING # noqa: F401 -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color +WHITE = util.xy_to_xyz(WHITES['2deg']['D65']) -def coerce_to_rgb(OrigColor: type[Color], cs: Space) -> tuple[type[Color], str]: + +def project_onto(a: Vector, b: Vector, o: Vector) -> Vector: + """ + Using 3 points, create two vectors with a shared origin and project the first vector onto the second. + + - `a`: point used to define the head of the first vector `OA`. + - `b`: point used to define the head of the second vector `OB`. + - `o`: the origin/tail point of both vector `OA` and `OB`. + """ + + # Create vector from points + ox, oy, oz = o + va1 = a[0] - ox + va2 = a[1] - oy + va3 = a[2] - oz + vb1 = b[0] - ox + vb2 = b[1] - oy + vb3 = b[2] - oz + + # Project `vec_oa` onto `vec_ob` and convert back to a point + n = (va1 * vb1 + va2 * vb2 + va3 * vb3) + d = (vb1 * vb1 + vb2 * vb2 + vb3 * vb3) + + if d == 0: # pragma: no cover + d = alg.EPS + r = n / d + + # Some spaces may project something that exceeds the range of our target vector. + if r > 1.0: + r = 1.0 + elif r < 0.0: # pragma: no cover + r = 0.0 + return [vb1 * r + ox, vb2 * r + oy, vb3 * r + oz] + + +def hwb_to_srgb(coords: Vector) -> Vector: # pragma: no cover + """Convert HWB to sRGB.""" + + return hsv_to_srgb(hwb_to_hsv(coords)) + + +def srgb_to_hwb(coords: Vector) -> Vector: # pragma: no cover + """Convert sRGB to HWB.""" + + return hsv_to_hwb(srgb_to_hsv(coords)) + + +def to_rect(coords: Vector, c:int, h: int) -> Vector: + """Polar to rectangular.""" + + coords[c], coords[h] = alg.polar_to_rect(coords[c], coords[h]) + return coords + + +def to_polar(coords: Vector, c:int, h: int) -> Vector: + """Rectangular to rectangular.""" + + coords[c], coords[h] = alg.rect_to_polar(coords[c], coords[h]) + return coords + + +@lru_cache(maxsize=20, typed=True) +def coerce_to_rgb(cs: Space) -> Space: """ Coerce an HSL, HSV, or HWB color space to RGB to allow us to ray trace the gamut. @@ -43,21 +109,21 @@ def coerce_to_rgb(OrigColor: type[Color], cs: Space) -> tuple[type[Color], str]: to_ = hwb_to_srgb from_ = srgb_to_hwb else: # pragma: no cover - raise ValueError('Cannot coerce {} to an RGB space.'.format(cs.NAME)) + raise ValueError(f'Cannot coerce {cs.NAME} to an RGB space.') class RGB(sRGBLinear): """Custom RGB class.""" - NAME = '-rgb-{}'.format(cs.NAME) + NAME = f'-rgb-{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] + INDEXES = cs.indexes() # Scale saturation and lightness (or HWB whiteness and blackness) - SCALE_SAT = cs.CHANNELS[INDEXES[1]].high - SCALE_LIGHT = cs.CHANNELS[INDEXES[1]].high + SCALE_SAT = cs.channels[INDEXES[1]].high + SCALE_LIGHT = cs.channels[INDEXES[2]].high def to_base(self, coords: Vector) -> Vector: """Convert from RGB to HSL.""" @@ -83,12 +149,7 @@ def from_base(self, coords: Vector) -> Vector: coords = to_(coords) return coords - class ColorRGB(OrigColor): # type: ignore[valid-type, misc] - """Custom color.""" - - ColorRGB.register(RGB()) - - return ColorRGB, RGB.NAME + return RGB() def raytrace_box( @@ -153,7 +214,7 @@ class RayTrace(Fit): """Gamut mapping by using ray tracing.""" NAME = "raytrace" - PSPACE = "lch-d65" + PSPACE = "oklch" def fit( self, @@ -161,109 +222,146 @@ def fit( space: str, *, pspace: str | None = None, - lch: str | None = None, + adaptive: float = 0.0, **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: + if 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. + # Requires an RGB-ish or Prism 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] + coerced = False + if not isinstance(cs, Prism) or isinstance(cs, Luminant): + coerced = True + cs = coerce_to_rgb(cs) + + # Get the maximum cube size, usually `[1.0, 1.0, 1.0]` + bmax = [chan.high for chan in cs.CHANNELS] + + # If there is a linear version of the RGB space, results will be better if we use that. + # Recalculate the bounding box relative to the linear version. + linear = cs.linear() 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] + subtractive = cs.SUBTRACTIVE + cs = color.CS_MAP[linear] + if subtractive != cs.SUBTRACTIVE: + bmax = color.new(space, [chan.low for chan in cs.CHANNELS]).convert(linear, in_place=True)[:-1] + else: + bmax = color.new(space, bmax).convert(linear, in_place=True)[:-1] space = linear + # Get the minimum bounds + bmin = [chan.low for chan in cs.CHANNELS] + orig = color.space() mapcolor = color.convert(pspace, norm=False) if orig != pspace else color.clone().normalize(nans=False) + polar = mapcolor._space.is_polar() 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 + # Different perceptual spaces may have components in different orders so capture their indexes + if polar: + l, c, h = achroma._space.indexes() + achroma[c] = 0.0 + else: + l, a, b = achroma._space.indexes() + achroma[a] = 0.0 + achroma[b] = 0.0 + + # If an alpha value is provided for adaptive lightness, calculate a lightness + # anchor point relative to the hue independent mid point. Scale lightness and + # chroma by the max lightness to get lightness between 0 and 1. + if adaptive: + max_light = color.new('xyz-d65', WHITE).convert(pspace, in_place=True)[l] + alight = adaptive_hue_independent( + mapcolor[l] / max_light, + max(mapcolor[c] if polar else alg.rect_to_polar(mapcolor[a], mapcolor[b])[0], 0) / max_light, + adaptive + ) * max_light + achroma[l] = alight 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 + alight = mapcolor[l] + + # Some perceptual spaces, such as CAM16 or HCT, may compensate for adapting + # luminance which may give an achromatic that is not quite achromatic. + # Project the lightness point back onto to the gamut's achromatic line. + anchor = cs.from_base(achroma.convert(space)[:-1]) if coerced else achroma.convert(space)[:-1] + anchor = project_onto(anchor, bmax, bmin) # 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]) + if anchor == bmax: + color.update(space, cs.to_base(bmax) if coerced else bmax, mapcolor[-1]) + elif anchor == bmin: + color.update(space, cs.to_base(bmin) if coerced else bmin, 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) + # Ensure we are handling coordinates in the polar space to better retain hue + if polar: + start = mapcolor[:-1] + end = achroma[:-1] + else: + start = to_polar(mapcolor[:-1], a, b) + end = to_polar(achroma[:-1], a, b) + end[b] = start[b] + + # Offset is required for some perceptual spaces that are sensitive + # to anchors that get too close to the surface. + offset = 1e-15 + + # Use an iterative process of casting rays to find the intersect with the RGB gamut + # and correcting the intersection onto the LCh chroma reduction path. + last = mapcolor.convert(space, in_place=True)[:-1] 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] + coords = mapcolor.convert(pspace, in_place=True, norm=False)[:-1] + + # Project the point onto the desired interpolation path in LCh if applying adaptive luminance + if adaptive: + if polar: + mapcolor[:-1] = project_onto(coords, start, end) + else: + mapcolor[:-1] = to_rect(project_onto(to_polar(coords, a, b), start, end), a, b) + + # For constant luminance, just correct lightness and hue in LCh else: - mapcolor[l] = light - mapcolor[h] = hue + coords[l] = start[l] + if polar: + coords[h] = start[h] + else: + to_polar(coords, a, b) + coords[b] = start[b] + to_rect(coords, a, b) + mapcolor[:-1] = coords + mapcolor.convert(space, in_place=True) - intersection = raytrace_box(achromatic, mapcolor[:-1], bmax=bmax) + + # Cast a ray and find the intersection with the gamut surface + coords = cs.from_base(mapcolor[:-1]) if coerced else mapcolor[:-1] + intersection = raytrace_box(anchor, coords, bmin=bmin, bmax=bmax) + + # Adjust anchor point closer to surface to improve results. + if i and all((bmin[r] + offset) < coords[r] < (bmax[r] - offset) for r in range(3)): + anchor = coords + + # Update color with the intersection point on the RGB surface. if intersection: - mapcolor[:-1] = intersection + last = cs.to_base(intersection) if coerced else intersection + mapcolor[:-1] = last continue + + # If we cannot find an intersection, reset to last good color + mapcolor[:-1] = last 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) + if coerced: + color.update( + space, + cs.to_base([alg.clamp(x, bmin[e], bmax[e]) for e, x in enumerate(cs.from_base(mapcolor[:-1]))]), + mapcolor[-1] + ) + else: + color.update(space, [alg.clamp(x, bmin[e], bmax[e]) for e, x in enumerate(mapcolor[:-1])], mapcolor[-1]) diff --git a/lib/coloraide/gamut/pointer.py b/lib/coloraide/gamut/pointer.py index 3f129af..e7be656 100644 --- a/lib/coloraide/gamut/pointer.py +++ b/lib/coloraide/gamut/pointer.py @@ -10,19 +10,19 @@ from ..spaces.lch import lab_to_lch, lch_to_lab from .. import algebra as alg from .. import util -from ..types import Vector, Matrix +from ..types import Vector, Matrix, AnyColor, VectorLike # noqa: F401 from typing import TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color # White point C as defined in the Pointer data spreadsheet XYZ_W = (98.0722647623506, 100.0, 118.225418982695) -WHITE_POINT_SC = tuple(util.xyz_to_xyY(XYZ_W)[:-1]) # type: tuple[float, float] # type: ignore[assignment] +WHITE_POINT_SC = tuple(util.xyz_to_xyY(XYZ_W)[:-1]) # type: VectorLike # Rows: hue 0 - 350 at steps of 10 # Columns: lightness 15 - 90 at steps of 5 -LCH_L = list(range(15, 91, 5)) -LCH_H = list(range(0, 351, 10)) +LCH_L = [*range(15, 91, 5)] +LCH_H = [*range(0, 351, 10)] LCH_POINTER = [ [10, 30, 43, 56, 68, 77, 79, 77, 72, 65, 57, 50, 40, 30, 19, 8], [15, 30, 45, 56, 64, 70, 73, 73, 71, 65, 57, 48, 39, 30, 18, 7], @@ -77,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: AnyColor, lch: Vector) -> AnyColor: """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)) @@ -142,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: AnyColor) -> AnyColor: """Fit a color to the Pointer gamut.""" # Convert to CIE LCh with the SC illuminant @@ -214,4 +214,4 @@ def pointer_gamut_boundary(lightness: float | None = None) -> Matrix: # Lightness exceeds threshold else: - raise ValueError('Lightness must be between {} and {}, but was {}'.format(LCH_L[0], LCH_L[-1], lightness)) + raise ValueError(f'Lightness must be between {LCH_L[0]} and {LCH_L[-1]}, but was {lightness}') diff --git a/lib/coloraide/gamut/tools.py b/lib/coloraide/gamut/tools.py new file mode 100644 index 0000000..1cdb45d --- /dev/null +++ b/lib/coloraide/gamut/tools.py @@ -0,0 +1,37 @@ +"""Common gamut related tools.""" +from __future__ import annotations +import math +from .. import algebra as alg + + +def adaptive_hue_independent(l: float, c: float, alpha: float = 0.05) -> float: + """ + Calculate a hue independent lightness anchor for the chroma compression. + + https://bottosson.github.io/posts/gamutclipping/#adaptive-%2C-hue-independent + + Copyright (c) 2021 Björn Ottosson + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + of the Software, and to permit persons to whom the Software is furnished to do + so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + """ + + ld = l - 0.5 + abs_ld = abs(ld) + e1 = 0.5 + abs_ld + alpha * c + return 0.5 * (1 + alg.sign(ld) * (e1 - math.sqrt(e1 ** 2 - 2.0 * abs_ld))) diff --git a/lib/coloraide/harmonies.py b/lib/coloraide/harmonies.py index 4a2798f..551df66 100644 --- a/lib/coloraide/harmonies.py +++ b/lib/coloraide/harmonies.py @@ -3,135 +3,116 @@ import math from abc import ABCMeta, abstractmethod from . import algebra as alg -from .spaces import Cylindrical, Labish, Regular, Space # noqa: F401 -from .spaces.hsl import HSL -from .spaces.lch import LCh +from .spaces import Labish, Luminant, Prism, Space # noqa: F401 +from .spaces.hsl import hsl_to_srgb, srgb_to_hsl from .cat import WHITES from . import util -from .types import Vector -from typing import TYPE_CHECKING, Any +from .types import Vector, AnyColor +from typing import Any, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from .color import Color WHITE = util.xy_to_xyz(WHITES['2deg']['D65']) BLACK = [0, 0, 0] -class _HarmonyLCh(LCh): - """Special LCh mapping class for harmonies.""" - - INDEXES = [0, 1, 2] - - def to_base(self, coords: Vector) -> Vector: - """Convert to the base.""" - - ordered = [0.0, 0.0, 0.0] - for e, c in enumerate(super().to_base(coords)): - ordered[self.INDEXES[e]] = c - return ordered - - def from_base(self, coords: Vector) -> Vector: - """Convert from the base.""" - - return super().from_base([coords[i] for i in self.INDEXES]) - - -class _HarmonyHSL(HSL): - """Special HSL mapping class for harmonies.""" +def adjust_hue(hue: float, deg: float) -> float: + """Adjust hue by the given degree.""" - INDEXES = [0, 1, 2] + return hue + deg - def to_base(self, coords: Vector) -> Vector: - """Convert to the base.""" +def get_cylinder(color: Color) -> tuple[Vector, int]: + """Return cylindrical values from a select number of color spaces on the fly.""" + + space = color.space() + + if color._space.is_polar(): + return color[:-1], color._space.hue_index() # type: ignore[attr-defined] + + cs = color.CS_MAP[color.space()] # type: Space + achromatic = color.is_achromatic() + + if isinstance(cs, Labish): + idx = cs.indexes() + values = color[:-1] + c, h = alg.rect_to_polar(values[idx[1]], values[idx[2]]) + return [values[idx[0]], c, h if not achromatic else alg.NaN], 2 + + if isinstance(cs, Prism) and not isinstance(cs, Luminant): + coords = color[:-1] + idx = cs.indexes() + offset_1 = cs.channels[idx[0]].low + offset_2 = cs.channels[idx[1]].low + offset_3 = cs.channels[idx[2]].low + + scale_1 = cs.channels[idx[0]].high + scale_2 = cs.channels[idx[1]].high + scale_3 = cs.channels[idx[2]].high + coords = [coords[i] for i in idx] + # Scale and offset the values such that channels are between 0 - 1 + coords[0] = (coords[0] - offset_1) / (scale_1 - offset_1) + coords[1] = (coords[1] - offset_2) / (scale_2 - offset_2) + coords[2] = (coords[2] - offset_3) / (scale_3 - offset_3) + hsl = srgb_to_hsl(coords) + if achromatic: + hsl[0] = alg.NaN + return hsl, 0 + + raise ValueError(f'Unsupported color space type {space}') # pragma: no cover + + +def from_cylinder(color: AnyColor, coords: Vector) -> AnyColor: + """From a cylinder values, convert back to the original color.""" + + space = color.space() + if color._space.is_polar(): + return color.new(space, coords, color[-1]) + + cs = color.CS_MAP[color.space()] # type: Space + + if isinstance(cs, Labish): + a, b = alg.polar_to_rect(coords[1], 0 if math.isnan(coords[2]) else coords[2]) + idx = cs.indexes() + lab = [0.0] * 3 + lab[idx[0]] = coords[0] + lab[idx[1]] = a + lab[idx[2]] = b + return color.new(space, lab, color[-1]) + + if isinstance(cs, Prism): + if math.isnan(coords[0]): + coords[0] = 0 + coords = hsl_to_srgb(coords) + idx = cs.indexes() + offset_1 = cs.channels[idx[0]].low + offset_2 = cs.channels[idx[1]].low + offset_3 = cs.channels[idx[2]].low + + scale_1 = cs.channels[idx[0]].high + scale_2 = cs.channels[idx[1]].high + scale_3 = cs.channels[idx[2]].high + # Scale and offset the values back to the origin space's configuration + coords[0] = coords[0] * (scale_1 - offset_1) + offset_1 + coords[1] = coords[1] * (scale_2 - offset_2) + offset_2 + coords[2] = coords[2] * (scale_3 - offset_3) + offset_3 ordered = [0.0, 0.0, 0.0] - for e, c in enumerate(super().to_base(coords)): - ordered[self.INDEXES[e]] = c - return ordered - - def from_base(self, coords: Vector) -> Vector: - """Convert from the base.""" + # Consistently order a given color spaces points based on its type + for e, c in enumerate(coords): + ordered[idx[e]] = c + return color.new(space, ordered, color[-1]) - return super().from_base([coords[i] for i in self.INDEXES]) - - -def adjust_hue(hue: float, deg: float) -> float: - """Adjust hue by the given degree.""" - - return hue + deg + raise ValueError(f'Unsupported color space type {space}') # pragma: no cover class Harmony(metaclass=ABCMeta): """Color harmony.""" @abstractmethod - def harmonize(self, color: Color, space: str) -> list[Color]: + def harmonize(self, color: AnyColor, space: str) -> list[AnyColor]: """Get color harmonies.""" - 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() - - if isinstance(color._space, Cylindrical): - return color - - if isinstance(color._space, Labish): - cs = color._space # type: Space - name = color.space() - - class HarmonyLCh(_HarmonyLCh): - NAME = '-harmony-cylinder' - SERIALIZE = ('---harmoncy-cylinder',) - BASE = name - 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.""" - - ColorCyl.register(HarmonyLCh()) - - return ColorCyl(color).convert('-harmony-cylinder') # type: ignore[no-any-return] - - if isinstance(color._space, Regular): - - cs = color._space - name = color.space() - - class HarmonyHSL(_HarmonyHSL, HSL): - NAME = '-harmony-cylinder' - 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.""" - - ColorCyl.register(HarmonyHSL()) - - return ColorCyl(color).convert('-harmony-cylinder') # type: ignore[no-any-return] - - raise ValueError('Unsupported color space type {}'.format(color.space())) - class Monochromatic(Harmony): """ @@ -148,19 +129,20 @@ class Monochromatic(Harmony): DELTA_E = '2000' - def harmonize(self, color: Color, space: str, count: int = 5) -> list[Color]: + def harmonize(self, color: AnyColor, space: str, count: int = 5) -> list[AnyColor]: """Get color harmonies.""" if count < 1: - raise ValueError('Cannot generate a monochromatic palette of {} colors.'.format(count)) + raise ValueError(f'Cannot generate a monochromatic palette of {count} colors.') # Convert color space color1 = color.convert(space, norm=False).normalize() - is_cyl = isinstance(color1._space, Cylindrical) + is_cyl = color1._space.is_polar() - if not is_cyl and not isinstance(color1._space, (Labish, Regular)): - raise ValueError('Unsupported color space type {}'.format(color.space())) + cs = color1._space + if not is_cyl and not isinstance(cs, Labish) and not (isinstance(cs, Prism) and not isinstance(cs, Luminant)): + raise ValueError(f'Unsupported color space type {color.space()}') # If only one color is requested, just return the current color. if count == 1: @@ -171,10 +153,10 @@ def harmonize(self, color: Color, space: str, count: int = 5) -> list[Color]: mask = ['hue', 'alpha'] if is_cyl else ['alpha'] 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) + w.convert(space, in_place=True, norm=False).fit().mask(mask, in_place=True) 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) + b.convert(space, in_place=True, norm=False).fit().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 @@ -245,37 +227,31 @@ class Geometric(Harmony): def __init__(self) -> None: """Initialize the count.""" + super().__init__() self.count = 12 - def harmonize(self, color: Color, space: str) -> list[Color]: + def harmonize(self, color: AnyColor, space: str) -> list[AnyColor]: """Get color harmonies.""" # Get the color cylinder - color1 = self.get_cylinder(color, space) - output = space - space = color1.space() - - name = color1._space.hue_name() # type: ignore[attr-defined] + color = color.convert(space, norm=False).normalize() + coords, h_idx = get_cylinder(color) + # Adjusts hue and convert to the final color degree = current = 360.0 / self.count - colors = [] + colors = [from_cylinder(color, coords)] for _ in range(self.count - 1): - colors.append( - color1.clone().set(name, lambda x, value=current: adjust_hue(x, value)) - ) + coords2 = coords[:] + coords2[h_idx] = adjust_hue(coords2[h_idx], current) + colors.append(from_cylinder(color, coords2)) current += degree - colors.insert(0, color1) - - # Using a dynamic cylinder, convert back to original color space - if output != space: - colors = [color.new(c.convert(output, in_place=True)) for c in colors] return colors class Wheel(Geometric): """Generate a color wheel.""" - def harmonize(self, color: Color, space: str, count: int = 12) -> list[Color]: + def harmonize(self, color: AnyColor, space: str, count: int = 12) -> list[AnyColor]: """Generate a color wheel with the given count.""" self.count = count @@ -312,66 +288,63 @@ def __init__(self) -> None: class SplitComplementary(Harmony): """Split Complementary colors.""" - def harmonize(self, color: Color, space: str) -> list[Color]: + def harmonize(self, color: AnyColor, space: str) -> list[AnyColor]: """Get color harmonies.""" # Get the color cylinder - color1 = self.get_cylinder(color, space) - output = space - space = color1.space() - name = color1._space.hue_name() # type: ignore[attr-defined] - - color2 = color1.clone().set(name, lambda x: adjust_hue(x, 210)) - color3 = color1.clone().set(name, lambda x: adjust_hue(x, -210)) - - # Using a dynamic cylinder, convert back to original color space - colors = [color1, color2, color3] - if output != space: - colors = [color.new(c.convert(output, in_place=True)) for c in colors] + color = color.convert(space, norm=False).normalize() + coords, h_idx = get_cylinder(color) + + # Adjusts hue and convert to the final color + colors = [from_cylinder(color, coords)] + clone = coords[:] + clone[h_idx] = adjust_hue(clone[h_idx], -210) + colors.append(from_cylinder(color, clone)) + coords[h_idx] = adjust_hue(coords[h_idx], 210) + colors.insert(0, from_cylinder(color, coords)) return colors class Analogous(Harmony): """Analogous colors.""" - def harmonize(self, color: Color, space: str) -> list[Color]: + def harmonize(self, color: AnyColor, space: str) -> list[AnyColor]: """Get color harmonies.""" - color1 = self.get_cylinder(color, space) - output = space - space = color1.space() - name = color1._space.hue_name() # type: ignore[attr-defined] - - color2 = color1.clone().set(name, lambda x: adjust_hue(x, 30)) - color3 = color1.clone().set(name, lambda x: adjust_hue(x, -30)) - - # Using a dynamic cylinder, convert back to original color space - colors = [color1, color2, color3] - if output != space: - colors = [color.new(c.convert(output, in_place=True)) for c in colors] + # Get the color cylinder + color = color.convert(space, norm=False).normalize() + coords, h_idx = get_cylinder(color) + + # Adjusts hue and convert to the final color + colors = [from_cylinder(color, coords)] + clone = coords[:] + clone[h_idx] = adjust_hue(clone[h_idx], 30) + colors.append(from_cylinder(color, clone)) + coords[h_idx] = adjust_hue(coords[h_idx], -30) + colors.insert(0, from_cylinder(color, coords)) return colors class TetradicRect(Harmony): """Tetradic (rectangular) colors.""" - def harmonize(self, color: Color, space: str) -> list[Color]: + def harmonize(self, color: AnyColor, space: str) -> list[AnyColor]: """Get color harmonies.""" # Get the color cylinder - color1 = self.get_cylinder(color, space) - output = space - space = color1.space() - name = color1._space.hue_name() # type: ignore[attr-defined] - - color2 = color1.clone().set(name, lambda x: adjust_hue(x, 30)) - color3 = color1.clone().set(name, lambda x: adjust_hue(x, 180)) - color4 = color1.clone().set(name, lambda x: adjust_hue(x, 210)) - - # Using a dynamic cylinder, convert back to original color space - colors = [color1, color2, color3, color4] - if output != space: - colors = [color.new(c.convert(output, in_place=True)) for c in colors] + color = color.convert(space, norm=False).normalize() + coords, h_idx = get_cylinder(color) + + # Adjusts hue and convert to the final color + colors = [from_cylinder(color, coords)] + clone = coords[:] + clone[h_idx] = adjust_hue(clone[h_idx], 30) + colors.append(from_cylinder(color, clone)) + clone = coords[:] + clone[h_idx] = adjust_hue(clone[h_idx], 180) + colors.append(from_cylinder(color, clone)) + coords[h_idx] = adjust_hue(coords[h_idx], 210) + colors.append(from_cylinder(color, coords)) return colors @@ -387,11 +360,11 @@ def harmonize(self, color: Color, space: str) -> list[Color]: } # type: dict[str, Harmony] -def harmonize(color: Color, name: str, space: str, **kwargs: Any) -> list[Color]: +def harmonize(color: AnyColor, name: str, space: str, **kwargs: Any) -> list[AnyColor]: """Get specified color harmonies.""" h = SUPPORTED.get(name) if not h: - raise ValueError("The color harmony '{}' cannot be found".format(name)) + raise ValueError(f"The color harmony '{name}' cannot be found") return h.harmonize(color, space, **kwargs) diff --git a/lib/coloraide/interpolate/__init__.py b/lib/coloraide/interpolate/__init__.py index abfeb1c..cc851af 100644 --- a/lib/coloraide/interpolate/__init__.py +++ b/lib/coloraide/interpolate/__init__.py @@ -18,14 +18,14 @@ import functools from abc import ABCMeta, abstractmethod from .. import algebra as alg -from .. spaces import HSVish, HSLish, Cylindrical, RGBish, LChish, Labish -from ..types import Matrix, Vector, ColorInput, Plugin -from typing import Callable, Sequence, Mapping, Any, TYPE_CHECKING +from .. spaces import HSVish, HSLish, RGBish, LChish, Labish +from ..types import Matrix, Vector, ColorInput, Plugin, AnyColor +from typing import Callable, Sequence, Mapping, Any, Generic, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color -__all__ = ('stop', 'hint', 'get_interpolator') +__all__ = ('stop', 'hint', 'interpolator', 'Interpolate', 'Interpolator') class stop: @@ -65,14 +65,14 @@ def normalize_domain(d: Vector) -> Vector: return values -class Interpolator(metaclass=ABCMeta): +class Interpolator(Generic[AnyColor], metaclass=ABCMeta): """Interpolator.""" def __init__( self, coordinates: Matrix, channel_names: Sequence[str], - create: type[Color], + color_cls: type[AnyColor], easings: list[Callable[..., float] | None], stops: dict[int, float], space: str, @@ -94,14 +94,14 @@ def __init__( self.coordinates = coordinates self.length = len(self.coordinates) self.channel_names = channel_names - self.create = create + self.color_cls = color_cls self.progress = progress self.space = space self._out_space = out_space self.extrapolate = extrapolate self.current_easing = None # type: Mapping[str, Callable[..., float]] | Callable[..., float] | None self.hue = hue - cs = self.create.CS_MAP[space] + cs = self.color_cls.CS_MAP[space] if cs.is_polar(): self.hue_index = cs.hue_index() # type: ignore[attr-defined] else: @@ -127,13 +127,13 @@ def discretize( max_delta_e: float = 0, delta_e: str | None = None, delta_e_args: dict[str, Any] | None = None, - ) -> Interpolator: + ) -> Interpolator[AnyColor]: """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, delta_e_args) + colors = self.steps(steps, max_steps, max_delta_e, delta_e, delta_e_args) # type: list[AnyColor] if not colors: raise ValueError('Discrete interpolation requires at least 1 discrete step.') @@ -154,17 +154,15 @@ def discretize( coords.extend([step1, step2]) count += 2 - 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, + color_cls=self.color_cls, easings=[None] * (len(coords) - 1), stops=stops, space=self.space, @@ -174,21 +172,21 @@ def discretize( extrapolate=self.extrapolate, domain=[], padding=None, - hue = hue + hue = 'shorter' ) def out_space(self, space: str) -> None: """Set output space.""" - if space not in self.create.CS_MAP: - raise ValueError("'{}' is not a valid color space".format(space)) + if space not in self.color_cls.CS_MAP: + raise ValueError(f"'{space}' is not a valid color space") self._out_space = space def padding(self, padding: float | Sequence[float]) -> None: """Add/adjust padding.""" # Make sure it is a sequence - padding = [padding] if not isinstance(padding, Sequence) else list(padding) + padding = [padding] if not isinstance(padding, Sequence) else [*padding] # If it is empty if not padding: @@ -254,7 +252,7 @@ def steps( max_delta_e: float = 0, delta_e: str | None = None, delta_e_args: dict[str, Any] | None = None, - ) -> list[Color]: + ) -> list[AnyColor]: """Steps.""" actual_steps = steps @@ -270,7 +268,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, AnyColor]] if actual_steps == 1: ret = [(0.5, self(0.5))] elif actual_steps > 1: @@ -317,9 +315,10 @@ def steps( total += 1 index += 2 - return [i[1] for i in ret] + return [ri[1] for ri in ret] def premultiply(self, coords: Vector, alpha: float | None = None) -> None: + """Apply premultiplication to semi-transparent colors.""" if alpha is not None: coords[-1] = alpha @@ -353,7 +352,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) -> AnyColor: """ Begin interpolation. @@ -383,7 +382,7 @@ def begin(self, point: float, first: float, last: float, index: int) -> Color: self.postdivide(coords) # Create the color and ensure it is in the correct color space. - color = self.create(self.space, coords[:-1], coords[-1]) + color = self.color_cls(self.space, coords[:-1], coords[-1]) return color.convert(self._out_space, in_place=True) def ease(self, t: float, channel_index: int) -> float: @@ -429,7 +428,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) -> AnyColor: """Find which leg of the interpolation the request is between.""" if self._domain: @@ -459,10 +458,10 @@ def __call__(self, point: float) -> Color: # We shouldn't ever hit this, but provided for typing. # If we do hit this, it would be a bug. - raise RuntimeError('Iterpolation could not be found for {}'.format(point)) # pragma: no cover + raise RuntimeError(f'Iterpolation could not be found for {point}') # pragma: no cover -class Interpolate(Plugin, metaclass=ABCMeta): +class Interpolate(Generic[AnyColor], Plugin, metaclass=ABCMeta): """Interpolation plugin.""" NAME = "" @@ -472,7 +471,7 @@ def interpolator( self, coordinates: Matrix, channel_names: Sequence[str], - create: type[Color], + color_cls: type[AnyColor], easings: list[Callable[..., float] | None], stops: dict[int, float], space: str, @@ -484,9 +483,20 @@ def interpolator( padding: float | tuple[float, float] | None = None, hue: str = 'shorter', **kwargs: Any - ) -> Interpolator: + ) -> Interpolator[AnyColor]: """Get the interpolator object.""" + def get_space(self, space: str | None, color_cls: type[AnyColor]) -> str: + """ + Get and validate the color space for interpolation. + + If no space is defined, return an appropriate default color space. + """ + + if space is None: + space = color_cls.INTERPOLATE + return space + def calc_stops(stops: dict[int, float], count: int) -> dict[int, float]: """Calculate stops.""" @@ -571,7 +581,7 @@ def carryforward_convert(color: Color, space: str, hue_index: int, powerless: bo needs_conversion = space != color.space() # Only look to "carry forward" if we have undefined channels - if needs_conversion and any(math.isnan(c) for c in color): # type: ignore[attr-defined] + if needs_conversion and any(math.isnan(c) for c in color): cs1 = color._space cs2 = color.CS_MAP[space] channels = { @@ -600,8 +610,8 @@ def carryforward_convert(color: Color, space: str, hue_index: int, powerless: bo for i, name in zip(cs1.indexes(), ('H', 'C', 'V')): if math.isnan(color[i]): channels[name] = True - elif isinstance(cs1, Cylindrical): - if math.isnan(color[cs1.hue_index()]): + elif cs1.is_polar(): + if math.isnan(color[cs1.hue_index()]): # type: ignore[attr-defined] channels['H'] = True # Carry alpha forward if undefined @@ -653,8 +663,8 @@ def carryforward_convert(color: Color, space: str, hue_index: int, powerless: bo def interpolator( + color_cls: type[AnyColor], interpolator: str, - create: type[Color], colors: Sequence[ColorInput | stop | Callable[..., float]], space: str | None, out_space: str | None, @@ -667,27 +677,26 @@ def interpolator( carryforward: bool = False, powerless: bool = False, **kwargs: Any -) -> Interpolator: +) -> Interpolator[AnyColor]: """Get desired blend mode.""" - plugin = create.INTERPOLATE_MAP.get(interpolator) + plugin = color_cls.INTERPOLATE_MAP.get(interpolator) if not plugin: - raise ValueError("'{}' is not a recognized interpolator".format(interpolator)) + raise ValueError(f"'{interpolator}' is not a recognized interpolator") # Construct piecewise interpolation object stops = {} # type: Any - if space is None: - space = create.INTERPOLATE + space = plugin.get_space(space, color_cls) if not colors: raise ValueError('At least one color must be specified.') if isinstance(colors[0], stop): - current = create(colors[0].color) + current = color_cls(colors[0].color) stops[0] = colors[0].stop elif not callable(colors[0]): - current = create(colors[0]) + current = color_cls(colors[0]) stops[0] = None else: raise ValueError('Cannot have an easing function as the first item in an interpolation list') @@ -697,7 +706,7 @@ def interpolator( # Adjust to space cs = current.CS_MAP[space] - is_cyl = isinstance(cs, Cylindrical) + is_cyl = cs.is_polar() hue_index = cs.hue_index() if is_cyl else -1 # type: ignore[attr-defined] if carryforward: carryforward_convert(current, space, hue_index, powerless) @@ -758,7 +767,7 @@ def interpolator( return plugin.interpolator( coords, current._space.channels, - create, + color_cls, easings, stops, space, diff --git a/lib/coloraide/interpolate/bspline.py b/lib/coloraide/interpolate/bspline.py index c75cac9..0307669 100644 --- a/lib/coloraide/interpolate/bspline.py +++ b/lib/coloraide/interpolate/bspline.py @@ -9,24 +9,22 @@ from .. import algebra as alg from .continuous import InterpolatorContinuous from ..interpolate import Interpolator, Interpolate -from ..types import Vector +from ..types import Vector, AnyColor from typing import Any -class InterpolatorBSpline(InterpolatorContinuous): +class InterpolatorBSpline(InterpolatorContinuous[AnyColor]): """Interpolate with B-spline.""" def adjust_endpoints(self) -> None: - """Adjust endpoints such that they are clamped and can handle extrapolation.""" - - # We cannot interpolate all the way to `coord[0]` and `coord[-1]` without additional control - # points to coax the curve through the end points. Generate a point at both ends so that we - # can properly evaluate the spline from start to finish. Additionally, when the extrapolating - # past the 0 - 1 boundary, provide some linear behavior - self.extrapolated = [ - list(zip(self.coordinates[0], self.coordinates[1])), - list(zip(self.coordinates[-2], self.coordinates[-1])) - ] + """ + Adjust endpoints such that they are clamped. + + We cannot interpolate all the way to `coord[0]` and `coord[-1]` without additional control + points to coax the curve through the end points. Generate a point at both ends so that we + can properly evaluate the spline from start to finish. + """ + self.coordinates.insert(0, [2 * a - b for a, b in zip(self.coordinates[0], self.coordinates[1])]) self.coordinates.append([2 * a - b for a, b in zip(self.coordinates[-1], self.coordinates[-2])]) @@ -46,7 +44,7 @@ def interpolate( """Interpolate.""" # Prepare in-boundary coordinates - coords = list(zip(*self.coordinates[index - 1:index + 3])) + coords = [*zip(*self.coordinates[index - 1:index + 3])] # Apply interpolation to each channel channels = [] @@ -56,10 +54,10 @@ def interpolate( # If `t` ends up spilling out past our boundaries, we need to extrapolate if self.extrapolate and index == 1 and point < 0.0: - p0, p1 = self.extrapolated[0][i] + p0, p1 = coords[i][1:3] channels.append(alg.lerp(p0, p1, t)) elif self.extrapolate and index == self.length - 1 and point > 1.0: - p0, p1 = self.extrapolated[1][i] + p0, p1 = coords[i][-3:-1] channels.append(alg.lerp(p0, p1, t)) else: p0, p1, p2, p3 = coords[i] @@ -72,12 +70,12 @@ def interpolate( return channels -class BSpline(Interpolate): +class BSpline(Interpolate[AnyColor]): """B-spline interpolation plugin.""" NAME = "bspline" - def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator[AnyColor]: """Return the B-spline interpolator.""" return InterpolatorBSpline(*args, **kwargs) diff --git a/lib/coloraide/interpolate/bspline_natural.py b/lib/coloraide/interpolate/bspline_natural.py index 78cc9b9..642c27d 100644 --- a/lib/coloraide/interpolate/bspline_natural.py +++ b/lib/coloraide/interpolate/bspline_natural.py @@ -7,10 +7,11 @@ from .. interpolate import Interpolate, Interpolator from .bspline import InterpolatorBSpline from .. import algebra as alg +from .. types import AnyColor from typing import Any -class InterpolatorNaturalBSpline(InterpolatorBSpline): +class InterpolatorNaturalBSpline(InterpolatorBSpline[AnyColor]): """Natural B-spline class.""" def setup(self) -> None: @@ -30,12 +31,12 @@ def setup(self) -> None: self.adjust_endpoints() -class NaturalBSpline(Interpolate): +class NaturalBSpline(Interpolate[AnyColor]): """Natural B-spline interpolation plugin.""" NAME = "natural" - def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator[AnyColor]: """Return the natural B-spline interpolator.""" return InterpolatorNaturalBSpline(*args, **kwargs) diff --git a/lib/coloraide/interpolate/catmull_rom.py b/lib/coloraide/interpolate/catmull_rom.py index 0a39f32..bd1d807 100644 --- a/lib/coloraide/interpolate/catmull_rom.py +++ b/lib/coloraide/interpolate/catmull_rom.py @@ -7,10 +7,11 @@ from .bspline import InterpolatorBSpline from ..interpolate import Interpolator, Interpolate from .. import algebra as alg +from .. types import AnyColor from typing import Any -class InterpolatorCatmullRom(InterpolatorBSpline): +class InterpolatorCatmullRom(InterpolatorBSpline[AnyColor]): """Interpolate with Catmull-Rom spline.""" def setup(self) -> None: @@ -20,12 +21,12 @@ def setup(self) -> None: self.spline = alg.catrom -class CatmullRom(Interpolate): +class CatmullRom(Interpolate[AnyColor]): """Catmull-Rom interpolation plugin.""" NAME = "catrom" - def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator[AnyColor]: """Return the Catmull-Rom interpolator.""" return InterpolatorCatmullRom(*args, **kwargs) diff --git a/lib/coloraide/interpolate/continuous.py b/lib/coloraide/interpolate/continuous.py index 1144455..274d7e5 100644 --- a/lib/coloraide/interpolate/continuous.py +++ b/lib/coloraide/interpolate/continuous.py @@ -3,7 +3,7 @@ import math from .. import algebra as alg from ..interpolate import Interpolator, Interpolate -from ..types import Vector +from ..types import Vector, AnyColor from typing import Any @@ -51,7 +51,7 @@ def adjust_decrease(h1: float, h2: float, offset: float) -> tuple[float, float]: return h2, offset -class InterpolatorContinuous(Interpolator): +class InterpolatorContinuous(Interpolator[AnyColor]): """Interpolate with continuous piecewise.""" def normalize_hue( @@ -88,9 +88,9 @@ def normalize_hue( elif hue == 'decreasing': adjuster = adjust_decrease else: - raise ValueError("Unknown hue adjuster '{}'".format(hue)) + raise ValueError(f"Unknown hue adjuster '{hue}'") - c1 = color1[index] + offset + c1 = color1[index] c2 = (color2[index] % 360) + offset # Adjust hue, handle gaps across `NaN`s @@ -156,7 +156,8 @@ def handle_undefined(self) -> None: # Two good values, store the last good value and continue if not a_nan and not b_nan: if self.premultiplied and i == alpha: - self.premultiply(c1) + if x == 1: + self.premultiply(c1) self.premultiply(c2) last = b continue @@ -235,12 +236,12 @@ def interpolate( return channels -class Continuous(Interpolate): +class Continuous(Interpolate[AnyColor]): """Continuous interpolation plugin.""" NAME = "continuous" - def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator[AnyColor]: """Return the continuous interpolator.""" return InterpolatorContinuous(*args, **kwargs) diff --git a/lib/coloraide/interpolate/css_linear.py b/lib/coloraide/interpolate/css_linear.py index 520b352..4366fcb 100644 --- a/lib/coloraide/interpolate/css_linear.py +++ b/lib/coloraide/interpolate/css_linear.py @@ -3,11 +3,11 @@ import math from .linear import InterpolatorLinear from ..interpolate import Interpolator, Interpolate -from ..types import Vector +from ..types import Vector, AnyColor from typing import Any -class InterpolatorCSSLinear(InterpolatorLinear): +class InterpolatorCSSLinear(InterpolatorLinear[AnyColor]): """Interpolate multiple ranges of colors using linear, Piecewise interpolation, but adhere to CSS requirements.""" def normalize_hue( @@ -74,18 +74,18 @@ def normalize_hue( c1 += 360 else: - raise ValueError("Unknown hue adjuster '{}'".format(hue)) + raise ValueError(f"Unknown hue adjuster '{hue}'") color1[index] = c1 color2[index] = c2 -class CSSLinear(Interpolate): +class CSSLinear(Interpolate[AnyColor]): """CSS Linear interpolation plugin.""" NAME = "css-linear" - def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator[AnyColor]: """Return the CSS linear interpolator.""" return InterpolatorCSSLinear(*args, **kwargs) diff --git a/lib/coloraide/interpolate/linear.py b/lib/coloraide/interpolate/linear.py index ebe5f9c..bc6f39a 100644 --- a/lib/coloraide/interpolate/linear.py +++ b/lib/coloraide/interpolate/linear.py @@ -3,11 +3,11 @@ import math from .. import algebra as alg from ..interpolate import Interpolator, Interpolate -from ..types import Vector +from ..types import Vector, AnyColor from typing import Any -class InterpolatorLinear(Interpolator): +class InterpolatorLinear(Interpolator[AnyColor]): """Interpolate multiple ranges of colors using linear, Piecewise interpolation.""" def normalize_hue( @@ -61,7 +61,7 @@ def normalize_hue( c1 += 360 else: - raise ValueError("Unknown hue adjuster '{}'".format(hue)) + raise ValueError(f"Unknown hue adjuster '{hue}'") color1[index] = c1 color2[index] = c2 @@ -145,12 +145,12 @@ def interpolate( return channels -class Linear(Interpolate): +class Linear(Interpolate[AnyColor]): """Linear interpolation plugin.""" NAME = "linear" - def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator[AnyColor]: """Return the linear interpolator.""" return InterpolatorLinear(*args, **kwargs) diff --git a/lib/coloraide/interpolate/monotone.py b/lib/coloraide/interpolate/monotone.py index 239bc76..3070de7 100644 --- a/lib/coloraide/interpolate/monotone.py +++ b/lib/coloraide/interpolate/monotone.py @@ -3,10 +3,11 @@ from .bspline import InterpolatorBSpline from ..interpolate import Interpolator, Interpolate from .. import algebra as alg +from .. types import AnyColor from typing import Any -class InterpolatorMonotone(InterpolatorBSpline): +class InterpolatorMonotone(InterpolatorBSpline[AnyColor]): """Interpolate with monotone spline based on Hermite.""" def setup(self) -> None: @@ -16,12 +17,12 @@ def setup(self) -> None: self.spline = alg.monotone -class Monotone(Interpolate): +class Monotone(Interpolate[AnyColor]): """Monotone interpolation plugin.""" NAME = "monotone" - def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator[AnyColor]: """Return the monotone interpolator.""" return InterpolatorMonotone(*args, **kwargs) diff --git a/lib/coloraide/spaces/__init__.py b/lib/coloraide/spaces/__init__.py index f7018e7..90fd0ee 100644 --- a/lib/coloraide/spaces/__init__.py +++ b/lib/coloraide/spaces/__init__.py @@ -4,20 +4,41 @@ from ..channels import Channel from ..css import serialize from ..types import VectorLike, Vector, Plugin -from typing import Any, TYPE_CHECKING, Sequence +from .. import deprecate +from typing import Any, TYPE_CHECKING, Callable, Sequence import math if TYPE_CHECKING: # pragma: no cover from ..color import Color +__deprecated__ = { + "Regular": "Prism" +} -class Regular: - """Regular 3D color space usually with a range between 0 - 1.""" + +def __getattr__(name: str) -> Any: # pragma: no cover + """Warn for deprecated attributes.""" + + deprecated = __deprecated__.get(name) + if deprecated: + deprecate.warn_deprecated(f"'{name}' is deprecated. Use '{deprecated}' instead.", stacklevel=3) + return globals()[deprecated] + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") + + +class Prism: + """Prism is a 3D rectangular prism.""" + + +class RGBish(Prism): + """RGB-ish space.""" class Cylindrical: """Cylindrical space.""" + get_channel_index: Callable[[str], int] + def radial_name(self) -> str: """Radial name.""" @@ -31,60 +52,37 @@ def hue_name(self) -> str: def hue_index(self) -> int: # pragma: no cover """Get hue index.""" - return self.get_channel_index(self.hue_name()) # type: ignore[no-any-return, attr-defined] + return self.get_channel_index(self.hue_name()) 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] + return self.get_channel_index(self.radial_name()) -class RGBish(Regular): - """RGB-ish space.""" +class Luminant: + """A space that contains luminance or luminance-like component.""" - def names(self) -> tuple[str, ...]: - """Return RGB-ish names in order R G B.""" + get_channel_index: Callable[[str], int] - return self.channels[:-1] # type: ignore[no-any-return, attr-defined] + def lightness_name(self) -> str: + """Lightness name.""" - def indexes(self) -> list[int]: - """Return the index of RGB-ish channels.""" + return "l" - return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] + def lightness_index(self) -> int: + """Get lightness index.""" - def linear(self) -> str: - """Will return the name of the space which is the linear version of itself (if available).""" + return self.get_channel_index(self.lightness_name()) - return '' - -class HSLish(Cylindrical): +class HSLish(Luminant, Cylindrical): """HSL-ish space.""" - 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]: - """Return the index of HSL-ish channels.""" - - return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] - -class HSVish(Cylindrical): +class HSVish(Luminant, Cylindrical): """HSV-ish space.""" - 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]: - """Return the index of HSV-ish channels.""" - - return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] - class HWBish(Cylindrical): """HWB-ish space.""" @@ -94,32 +92,12 @@ def radial_name(self) -> str: 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]: - """Return the index of HWB-ish channels.""" - - return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] - - -class Labish: +class Labish(Luminant, Prism): """Lab-ish color spaces.""" - 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 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] - -class LChish(Cylindrical): +class LChish(Luminant, Cylindrical): """LCh-ish color spaces.""" def radial_name(self) -> str: @@ -127,16 +105,6 @@ def radial_name(self) -> str: return "c" - 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]: - """Return the index of the Lab-ish channels.""" - - return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] - alpha_channel = Channel('alpha', 0.0, 1.0, bound=True, limit=(0.0, 1.0)) @@ -180,31 +148,42 @@ class Space(Plugin, metaclass=SpaceMeta): # 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 - # ranges, then the colors will not be gamut mapped even if their gamut is larger than the target interpolation - # space. - EXTENDED_RANGE = False # White point WHITE = (0.0, 0.0) # What is the color space's dynamic range DYNAMIC_RANGE = 'sdr' + # Is the space subtractive + SUBTRACTIVE = False def __init__(self, **kwargs: Any) -> None: """Initialize.""" - self.channels = self.CHANNELS + (alpha_channel,) + 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 names(self) -> tuple[Channel, ...]: + """Returns component names in a logical order specific to their color space type.""" + + return self.channels[:-1] + + def indexes(self) -> list[int]: + """Returns component indexes in a logical order specific to their color space type.""" + + return [self.get_channel_index(name) for name in self.names()] + def is_polar(self) -> bool: """Return if the space is polar.""" return self._polar + def linear(self) -> str: + """Will return the name of the space which is the linear version of itself (if available).""" + + return '' + def get_channel_index(self, name: str) -> int: """Get channel index.""" @@ -258,7 +237,8 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, fit: str | bool | dict[str, Any] = True, none: bool = False, percent: bool | Sequence[bool] = False, @@ -271,6 +251,7 @@ def to_string( color=True, alpha=alpha, precision=precision, + rounding=rounding, fit=fit, none=none, percent=percent diff --git a/lib/coloraide/spaces/a98_rgb.py b/lib/coloraide/spaces/a98_rgb.py index f23f996..635aea2 100644 --- a/lib/coloraide/spaces/a98_rgb.py +++ b/lib/coloraide/spaces/a98_rgb.py @@ -1,4 +1,8 @@ -"""A98 RGB color class.""" +""" +A98 RGB color class. + +- https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf +""" from __future__ import annotations from .srgb_linear import sRGBLinear from .. import algebra as alg diff --git a/lib/coloraide/spaces/a98_rgb_linear.py b/lib/coloraide/spaces/a98_rgb_linear.py index e0168db..70d331d 100644 --- a/lib/coloraide/spaces/a98_rgb_linear.py +++ b/lib/coloraide/spaces/a98_rgb_linear.py @@ -1,4 +1,8 @@ -"""Linear A98 RGB color class.""" +""" +Linear A98 RGB color class. + +- https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf +""" from __future__ import annotations from .srgb_linear import sRGBLinear from .. import algebra as alg @@ -27,13 +31,13 @@ def lin_a98rgb_to_xyz(rgb: Vector) -> Vector: https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf """ - return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul_x3(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.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul_x3(XYZ_TO_RGB, xyz, dims=alg.D2_D1) class A98RGBLinear(sRGBLinear): diff --git a/lib/coloraide/spaces/aces2065_1.py b/lib/coloraide/spaces/aces2065_1.py index 2efc700..9bd100a 100644 --- a/lib/coloraide/spaces/aces2065_1.py +++ b/lib/coloraide/spaces/aces2065_1.py @@ -7,6 +7,7 @@ from ..channels import Channel from ..spaces.srgb_linear import sRGBLinear from .. import algebra as alg +from ..cat import WHITES from ..types import Vector AP0_TO_XYZ = [ @@ -28,13 +29,13 @@ def aces_to_xyz(aces: Vector) -> Vector: """Convert ACEScc to XYZ.""" - return alg.matmul(AP0_TO_XYZ, aces, dims=alg.D2_D1) + return alg.matmul_x3(AP0_TO_XYZ, aces, dims=alg.D2_D1) def xyz_to_aces(xyz: Vector) -> Vector: """Convert XYZ to ACEScc.""" - return alg.matmul(XYZ_TO_AP0, xyz, dims=alg.D2_D1) + return alg.matmul_x3(XYZ_TO_AP0, xyz, dims=alg.D2_D1) class ACES20651(sRGBLinear): @@ -43,7 +44,7 @@ class ACES20651(sRGBLinear): BASE = "xyz-d65" NAME = "aces2065-1" SERIALIZE = ("--aces2065-1",) - WHITE = (0.32168, 0.33767) + WHITE = WHITES['2deg']['ACES-D60'] CHANNELS = ( Channel("r", 0.0, 65504.0, bound=True), Channel("g", 0.0, 65504.0, bound=True), diff --git a/lib/coloraide/spaces/acescc.py b/lib/coloraide/spaces/acescc.py index a1bfc7a..5686d4e 100644 --- a/lib/coloraide/spaces/acescc.py +++ b/lib/coloraide/spaces/acescc.py @@ -7,6 +7,7 @@ import math from ..channels import Channel from ..spaces.srgb_linear import sRGBLinear +from ..cat import WHITES from ..types import Vector CC_MIN = (math.log2(2 ** -16) + 9.72) / 17.52 @@ -56,7 +57,7 @@ class ACEScc(sRGBLinear): BASE = "acescg" NAME = "acescc" SERIALIZE = ("--acescc",) # type: tuple[str, ...] - WHITE = (0.32168, 0.33767) + WHITE = WHITES['2deg']['ACES-D60'] CHANNELS = ( Channel("r", CC_MIN, CC_MAX, bound=True, nans=CC_MIN), Channel("g", CC_MIN, CC_MAX, bound=True, nans=CC_MIN), diff --git a/lib/coloraide/spaces/acescct.py b/lib/coloraide/spaces/acescct.py index 5b0eb16..a194aca 100644 --- a/lib/coloraide/spaces/acescct.py +++ b/lib/coloraide/spaces/acescct.py @@ -7,8 +7,9 @@ import math from ..channels import Channel from ..spaces.srgb_linear import sRGBLinear -from ..types import Vector from .acescc import CC_MAX +from ..cat import WHITES +from ..types import Vector CCT_MIN = 0.0729055341958355 CCT_MAX = CC_MAX @@ -51,7 +52,7 @@ class ACEScct(sRGBLinear): BASE = "acescg" NAME = "acescct" SERIALIZE = ("--acescct",) # type: tuple[str, ...] - WHITE = (0.32168, 0.33767) + WHITE = WHITES['2deg']['ACES-D60'] CHANNELS = ( Channel("r", CCT_MIN, CCT_MAX, bound=True, nans=CCT_MIN), Channel("g", CCT_MIN, CCT_MAX, bound=True, nans=CCT_MIN), diff --git a/lib/coloraide/spaces/acescg.py b/lib/coloraide/spaces/acescg.py index ad6394d..1cd4227 100644 --- a/lib/coloraide/spaces/acescg.py +++ b/lib/coloraide/spaces/acescg.py @@ -7,6 +7,7 @@ from ..channels import Channel from ..spaces.srgb_linear import sRGBLinear from .. import algebra as alg +from ..cat import WHITES from ..types import Vector AP1_TO_XYZ = [ @@ -25,13 +26,13 @@ def acescg_to_xyz(acescg: Vector) -> Vector: """Convert ACEScc to XYZ.""" - return alg.matmul(AP1_TO_XYZ, acescg, dims=alg.D2_D1) + return alg.matmul_x3(AP1_TO_XYZ, acescg, dims=alg.D2_D1) def xyz_to_acescg(xyz: Vector) -> Vector: """Convert XYZ to ACEScc.""" - return alg.matmul(XYZ_TO_AP1, xyz, dims=alg.D2_D1) + return alg.matmul_x3(XYZ_TO_AP1, xyz, dims=alg.D2_D1) class ACEScg(sRGBLinear): @@ -40,7 +41,7 @@ class ACEScg(sRGBLinear): BASE = "xyz-d65" NAME = "acescg" SERIALIZE = ("--acescg",) # type: tuple[str, ...] - WHITE = (0.32168, 0.33767) + WHITE = WHITES['2deg']['ACES-D60'] CHANNELS = ( Channel("r", 0.0, 65504.0, bound=True), Channel("g", 0.0, 65504.0, bound=True), diff --git a/lib/coloraide/spaces/cam02.py b/lib/coloraide/spaces/cam02.py new file mode 100644 index 0000000..d9ba7d6 --- /dev/null +++ b/lib/coloraide/spaces/cam02.py @@ -0,0 +1,313 @@ +""" +CAM02 class (JMh). + +https://www.researchgate.net/publication/318152296_Comprehensive_color_solutions_CAM16_CAT16_and_CAM16-UCS +https://en.wikipedia.org/wiki/CIECAM02 +https://www.researchgate.net/publication/221501922_The_CIECAM02_color_appearance_model +https://arxiv.org/abs/1802.06067 +""" +from __future__ import annotations +import math +from .. import util +from .. import algebra as alg +from .lch import LCh +from ..cat import WHITES, CAT02 +from ..channels import Channel, FLG_ANGLE +from ..types import Vector +from .cam16 import ( + M1, + hue_quadrature, + inv_hue_quadrature, + eccentricity, + adapt, + unadapt +) +from .cam16 import Environment as _Environment + +# CAT02 +M02 = CAT02.MATRIX +M02_INV = [ + [1.0961238208355142, -0.27886900021828726, 0.18274517938277304], + [0.45436904197535916, 0.4735331543074118, 0.07209780371722913], + [-0.009627608738429355, -0.00569803121611342, 1.0153256399545427] +] + +XYZ_TO_HPE = [ + [0.38971, 0.68898, -0.07868], + [-0.22981, 1.18340, 0.04641], + [0.00000, 0.00000, 1.00000], +] + +HPE_TO_XYZ = [ + [1.910196834052035, -1.1121238927878747, 0.20190795676749937], + [0.3709500882486886, 0.6290542573926132, -8.055142184361326e-06], + [0.0, 0.0, 1.0] +] + + +class Environment(_Environment): + """ + Class to calculate and contain any required environmental data (viewing conditions included). + + Usage Guidelines for CIECAM97s (Nathan Moroney) + https://www.researchgate.net/publication/220865484_Usage_guidelines_for_CIECAM97s + + `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 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 calculate_adaptation(self, xyz_w: Vector) -> None: + """Calculate the adaptation of the reference point and related variables.""" + + # Cone response for reference white + self.rgb_w = alg.matmul_x3(M02, xyz_w, dims=alg.D2_D1) + + self.d_rgb = [(self.yw * (self.d / coord) + 1 - self.d) for coord in self.rgb_w] + self.d_rgb_inv = [1 / coord for coord in self.d_rgb] + self.rgb_cw = alg.multiply_x3(self.d_rgb, self.rgb_w, dims=alg.D1) + self.rgb_pw = alg.matmul_x3(alg.matmul_x3(XYZ_TO_HPE, M02_INV), self.rgb_cw) + + # Achromatic response + rgb_aw = adapt(self.rgb_pw, self.fl) + self.a_w = self.nbb * (2 * rgb_aw[0] + rgb_aw[1] + 0.05 * rgb_aw[2]) + + +def cam_to_xyz( + 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 CAM02 to XYZ. + + Reverse calculation can actually be obtained from a small subset of the CAM02 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 ((J is not None) ^ (Q is not None)): + raise ValueError("Conversion requires one and only one: 'J' or 'Q'") + + if not ((C is not None) ^ (M is not None) ^ (s is not None)): + raise ValueError("Conversion requires one and only one: 'C', 'M' or 's'") + + # Hue is absolutely required + if not ((h is not None) ^ (H is not None)): + raise ValueError("Conversion requires one and only one: 'h' or 'H'") + + # We need viewing conditions + if env is None: + raise ValueError("No viewing conditions/environment provided") + + # Black? + if J == 0.0: + J = alg.EPS + if not any((C, M, s)): + return [0.0, 0.0, 0.0] + if Q == 0.0: + Q = alg.EPS + if not any((C, M, s)): + return [0.0, 0.0, 0.0] + + # Calculate hue + h_rad = 0.0 + if h is not None: + h_rad = math.radians(h % 360) + elif H is not None: + h_rad = math.radians(inv_hue_quadrature(H)) + + # Calculate `J_root` from one of the lightness derived coordinates. + J_root = 0.0 + if J is not None: + J_root = alg.nth_root(J, 2) * 0.1 + elif Q is not None: + J_root = 0.25 * env.c * Q / ((env.a_w + 4) * env.fl_root) + + # Calculate the `t` value from one of the chroma derived coordinates + alpha = 0.0 + if C is not None: + alpha = C / J_root + elif M is not None: + 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.spow(alpha * math.pow(1.64 - math.pow(0.29, env.n), -0.73), 10 / 9) + + # Eccentricity + et = eccentricity(h_rad) + + # Achromatic response + A = env.a_w * alg.spow(J_root, 2 / env.c / env.z) + + # Calculate red-green and yellow-blue components from hue + cos_h = math.cos(h_rad) + sin_h = math.sin(h_rad) + p1 = 5e4 / 13 * env.nc * env.ncb * et + p2 = A / env.nbb + 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_a = alg.multiply_x3(alg.matmul_x3(M1, [p2, a, b], dims=alg.D2_D1), 1 / 1403, dims=alg.D1_SC) + rgb_c = alg.matmul_x3(alg.matmul_x3(M02, HPE_TO_XYZ, dims=alg.D2), unadapt(rgb_a, env.fl), dims=alg.D2_D1) + return util.scale1(alg.matmul_x3(M02_INV, alg.multiply_x3(rgb_c, env.d_rgb_inv, dims=alg.D1), dims=alg.D2_D1)) + + +def xyz_to_cam(xyz: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector: + """From XYZ to CAM02.""" + + # Calculate cone response + rgb_c = alg.multiply_x3( + env.d_rgb, + alg.matmul_x3(M02, util.scale100(xyz), dims=alg.D2_D1), + dims=alg.D1 + ) + rgb_a = adapt(alg.matmul_x3(alg.matmul_x3(XYZ_TO_HPE, M02_INV, dims=alg.D2), rgb_c, dims=alg.D2_D1), env.fl) + + # Calculate red-green and yellow components and resultant hue + p2 = 2 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2] + a = rgb_a[0] + (-12 * rgb_a[1] + rgb_a[2]) / 11 + b = (rgb_a[0] + rgb_a[1] - 2 * rgb_a[2]) / 9 + u = rgb_a[0] + rgb_a[1] + 1.05 * rgb_a[2] + h_rad = math.atan2(b, a) % math.tau + + # Eccentricity + et = eccentricity(h_rad) + + # Calculate `t` so we can calculate `alpha` + p1 = 5e4 / 13 * env.nc * env.ncb * et + t = alg.zdiv(p1 * math.sqrt(a ** 2 + b ** 2), u + 0.305) + alpha = alg.spow(t, 0.9) * math.pow(1.64 - math.pow(0.29, env.n), 0.73) + + # Achromatic response + A = env.nbb * p2 + + # Lightness + J = 100 * alg.spow(A / env.a_w, env.c * env.z) + J_root = alg.nth_root(J / 100, 2) + + # Brightness + Q = (4 / env.c * J_root * (env.a_w + 4) * env.fl_root) + + # Chroma + C = alpha * J_root + + # Colorfulness + M = C * env.fl_root + + # Saturation + s = 50 * alg.nth_root(env.c * alpha / (env.a_w + 4), 2) + + # Hue + h = util.constrain_hue(math.degrees(h_rad)) + + # Hue quadrature + H = hue_quadrature(h) if calc_hue_quadrature else alg.NaN + + return [J, C, h, s, Q, M, H] + + +def xyz_to_cam_jmh(xyz: Vector, env: Environment) -> Vector: + """XYZ to CAM02 JMh.""" + + cam = xyz_to_cam(xyz, env) + J, M, h = cam[0], cam[5], cam[2] + return [J, M, h] + + +def cam_jmh_to_xyz(jmh: Vector, env: Environment) -> Vector: + """CAM02 JMh to XYZ.""" + + J, M, h = jmh + return cam_to_xyz(J=J, M=M, h=h, env=env) + + +class CAM02JMh(LCh): + """CAM02 class (JMh).""" + + BASE = "xyz-d65" + NAME = "cam02-jmh" + SERIALIZE = ("--cam02-jmh",) + CHANNEL_ALIASES = { + "lightness": "j", + "colorfulness": 'm', + "hue": 'h' + } + WHITE = WHITES['2deg']['D65'] + # 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), + Channel("m", 0, 120.0), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "j" + + def radial_name(self) -> str: + """Get radial name.""" + + return "m" + + 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 to_base(self, coords: Vector) -> Vector: + """From CAM02 JMh to XYZ.""" + + return cam_jmh_to_xyz(coords, self.ENV) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ to CAM02 JMh.""" + + return xyz_to_cam_jmh(coords, self.ENV) diff --git a/lib/coloraide/spaces/cam02_ucs.py b/lib/coloraide/spaces/cam02_ucs.py new file mode 100644 index 0000000..d663646 --- /dev/null +++ b/lib/coloraide/spaces/cam02_ucs.py @@ -0,0 +1,80 @@ +"""CAM02 UCS.""" +from __future__ import annotations +from .cam02 import xyz_to_cam, cam_to_xyz, CAM02JMh +from .cam16_ucs import cam_jmh_to_cam_ucs, cam_ucs_to_cam_jmh +from .lab import Lab +from ..cat import WHITES +from ..channels import Channel, FLG_MIRROR_PERCENT +from ..types import Vector + + +class CAM02UCS(Lab): + """CAM02 UCS (Jab) class.""" + + BASE = "cam02-jmh" + NAME = "cam02-ucs" + SERIALIZE = ("--cam02-ucs",) + MODEL = 'ucs' + CHANNELS = ( + 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 CAM02JMh + ENV = CAM02JMh.ENV + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "j" + + def is_achromatic(self, coords: Vector) -> bool: + """Check if color is achromatic.""" + + m = cam_ucs_to_cam_jmh(coords, self.MODEL)[1] + return abs(m) < self.achromatic_threshold + + def to_base(self, coords: Vector) -> Vector: + """To base from UCS.""" + + return cam_ucs_to_cam_jmh(coords, self.MODEL) + + def from_base(self, coords: Vector) -> Vector: + """From base to UCS.""" + + # Account for negative colorfulness by reconverting as this can many times corrects the problem + if coords[1] < 0: + cam16 = xyz_to_cam(cam_to_xyz(J=coords[0], M=coords[1], h=coords[2], env=self.ENV), env=self.ENV) + coords = [cam16[0], cam16[5], cam16[2]] + + return cam_jmh_to_cam_ucs(coords, self.MODEL) + + +class CAM02LCD(CAM02UCS): + """CAM02 LCD (Jab) class.""" + + NAME = "cam02-lcd" + SERIALIZE = ("--cam02-lcd",) + MODEL = 'lcd' + CHANNELS = ( + 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) + ) + + +class CAM02SCD(CAM02UCS): + """CAM02 SCD (Jab) class.""" + + NAME = "cam02-scd" + SERIALIZE = ("--cam02-scd",) + MODEL = 'scd' + CHANNELS = ( + 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/cam16.py b/lib/coloraide/spaces/cam16.py new file mode 100644 index 0000000..2135ff7 --- /dev/null +++ b/lib/coloraide/spaces/cam16.py @@ -0,0 +1,423 @@ +""" +CAM16 class (JMh). + +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 +""" +from __future__ import annotations +import math +import bisect +from .. import util +from .. import algebra as alg +from .lch import LCh +from ..cat import WHITES, CAT16 +from ..channels import Channel, FLG_ANGLE +from ..types import Vector, VectorLike + +# CAT16 +M16 = CAT16.MATRIX +M16_INV = [ + [1.8620678550872327, -1.0112546305316843, 0.14918677544445175], + [0.38752654323613717, 0.6214474419314753, -0.008973985167612518], + [-0.015841498849333856, -0.03412293802851557, 1.0499644368778496] +] + +M1 = [ + [460.0, 451.0, 288.0], + [460.0, -891.0, -261.0], + [460.0, -220.0, -6300.0] +] + +ADAPTED_COEF = 0.42 +ADAPTED_COEF_INV = 1 / ADAPTED_COEF + +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": (20.14, 90.00, 164.25, 237.53, 380.14), + "e": (0.8, 0.7, 1.0, 1.2, 0.8), + "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 eccentricity(h: float) -> float: + """Calculate eccentricity.""" + + return 0.25 * (math.cos(h + 2) + 3.8) + + +def inv_hue_quadrature(H: float) -> float: + """Hue quadrature to hue.""" + + Hp = (H % 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(coords: Vector, fl: float) -> Vector: + """Adapt the coordinates.""" + + adapted = [] + for c in coords: + x = (fl * abs(c) * 0.01) ** ADAPTED_COEF + adapted.append(400 * math.copysign(x, c) / (x + 27.13)) + return adapted + + +def unadapt(adapted: Vector, fl: float) -> Vector: + """Remove adaptation from coordinates.""" + + coords = [] + constant = 100 / fl * (27.13 ** ADAPTED_COEF_INV) + for c in adapted: + cabs = abs(c) + coords.append(math.copysign(constant * alg.spow(cabs / (400 - cabs), ADAPTED_COEF_INV), c)) + return coords + + +class Environment: + """ + Class to calculate and contain any required environmental data (viewing conditions included). + + Usage Guidelines for CIECAM97s (Nathan Moroney) + https://www.researchgate.net/publication/220865484_Usage_guidelines_for_CIECAM97s + + `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 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, + 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.discounting = discounting + self.ref_white = util.xy_to_xyz(white) + self.surround = surround + + # 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) + self.yw = xyz_w[1] + + # Surround: dark, dim, and average + f, self.c, self.nc = SURROUND[self.surround] + + k = 1 / (5 * self.la + 1) + k4 = k ** 4 + + # Factor of luminance level adaptation + self.fl = (k4 * self.la + 0.1 * (1 - k4) * (1 - k4) * math.pow(5 * self.la, 1 / 3)) + self.fl_root = math.pow(self.fl, 0.25) + + self.n = self.yb / self.yw + self.z = 1.48 + math.sqrt(self.n) + self.nbb = 0.725 * math.pow(self.n, -0.2) + self.ncb = self.nbb + + # 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 discounting else 1 + self.calculate_adaptation(xyz_w) + + def calculate_adaptation(self, xyz_w: Vector) -> None: + """Calculate the adaptation of the reference point and related variables.""" + + # Cone response for reference white + self.rgb_w = alg.matmul_x3(M16, xyz_w, dims=alg.D2_D1) + + self.d_rgb = [alg.lerp(1, self.yw / coord, self.d) for coord in self.rgb_w] + self.d_rgb_inv = [1 / coord for coord in self.d_rgb] + + # Achromatic response + self.rgb_cw = alg.multiply_x3(self.rgb_w, self.d_rgb, dims=alg.D1) + rgb_aw = adapt(self.rgb_cw, self.fl) + self.a_w = self.nbb * (2 * rgb_aw[0] + rgb_aw[1] + 0.05 * rgb_aw[2]) + + +def cam_to_xyz( + 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. + + Reverse calculation can actually be obtained from a small subset of the CAM16 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 ((J is not None) ^ (Q is not None)): + raise ValueError("Conversion requires one and only one: 'J' or 'Q'") + + if not ((C is not None) ^ (M is not None) ^ (s is not None)): + raise ValueError("Conversion requires one and only one: 'C', 'M' or 's'") + + # Hue is absolutely required + if not ((h is not None) ^ (H is not None)): + raise ValueError("Conversion requires one and only one: 'h' or 'H'") + + # We need viewing conditions + if env is None: + raise ValueError("No viewing conditions/environment provided") + + # Shortcut out if black. + # If lightness is zero, but chroma/colorfulness/saturation is not zero, + # Set J to a very small value to avoid divisions by zero. + if J == 0.0: + J = alg.EPS + if not any((C, M, s)): + return [0.0, 0.0, 0.0] + if Q == 0.0: + Q = alg.EPS + if not any((C, M, s)): + return [0.0, 0.0, 0.0] + + # Calculate hue + h_rad = 0.0 + if h is not None: + h_rad = math.radians(h % 360) + elif H is not None: + h_rad = math.radians(inv_hue_quadrature(H)) + + # Calculate `J_root` from one of the lightness derived coordinates. + J_root = 0.0 + if J is not None: + J_root = alg.nth_root(J, 2) * 0.1 + elif Q is not None: + J_root = 0.25 * env.c * Q / ((env.a_w + 4) * env.fl_root) + + # Calculate the `t` value from one of the chroma derived coordinates + alpha = 0.0 + if C is not None: + alpha = C / J_root + elif M is not None: + 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.spow(alpha * math.pow(1.64 - math.pow(0.29, env.n), -0.73), 10 / 9) + + # Eccentricity + et = eccentricity(h_rad) + + # Achromatic response + A = env.a_w * alg.spow(J_root, 2 / env.c / env.z) + + # Calculate red-green and yellow-blue components + cos_h = math.cos(h_rad) + sin_h = math.sin(h_rad) + p1 = 5e4 / 13 * env.nc * env.ncb * et + p2 = A / env.nbb + 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_a = alg.multiply_x3(alg.matmul_x3(M1, [p2, a, b], dims=alg.D2_D1), 1 / 1403, dims=alg.D1_SC) + rgb_c = unadapt(rgb_a, env.fl) + return util.scale1(alg.matmul_x3(M16_INV, alg.multiply_x3(rgb_c, env.d_rgb_inv, dims=alg.D1), dims=alg.D2_D1)) + + +def xyz_to_cam(xyz: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector: + """From XYZ to CAM16.""" + + # Calculate cone response + rgb_c = alg.multiply_x3( + alg.matmul_x3(M16, util.scale100(xyz), dims=alg.D2_D1), + env.d_rgb, + dims=alg.D1 + ) + rgb_a = adapt(rgb_c, env.fl) + + # Calculate red-green and yellow components and resultant hue + p2 = 2 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2] + a = rgb_a[0] + (-12 * rgb_a[1] + rgb_a[2]) / 11 + b = (rgb_a[0] + rgb_a[1] - 2 * rgb_a[2]) / 9 + u = rgb_a[0] + rgb_a[1] + 1.05 * rgb_a[2] + h_rad = math.atan2(b, a) % math.tau + + # Eccentricity + et = eccentricity(h_rad) + + # Calculate `t` so we can calculate `alpha` + p1 = 5e4 / 13 * env.nc * env.ncb * et + t = alg.zdiv(p1 * math.sqrt(a ** 2 + b ** 2), u + 0.305) + alpha = alg.spow(t, 0.9) * math.pow(1.64 - math.pow(0.29, env.n), 0.73) + + # Achromatic response + A = env.nbb * p2 + + # Lightness + J = 100 * alg.spow(A / env.a_w, env.c * env.z) + J_root = alg.nth_root(J / 100, 2) + + # Brightness + Q = (4 / env.c * J_root * (env.a_w + 4) * env.fl_root) + + # Chroma + C = alpha * J_root + + # Colorfulness + M = C * env.fl_root + + # Saturation + s = 50 * alg.nth_root(env.c * alpha / (env.a_w + 4), 2) + + # Hue + h = util.constrain_hue(math.degrees(h_rad)) + + # Hue quadrature + H = hue_quadrature(h) if calc_hue_quadrature else alg.NaN + + return [J, C, h, s, Q, M, H] + + +def xyz_to_cam_jmh(xyz: Vector, env: Environment) -> Vector: + """XYZ to CAM16 JMh.""" + + cam16 = xyz_to_cam(xyz, env) + J, M, h = cam16[0], cam16[5], cam16[2] + return [J, M, h] + + +def cam_jmh_to_xyz(jmh: Vector, env: Environment) -> Vector: + """CAM16 JMh to XYZ.""" + + J, M, h = jmh + return cam_to_xyz(J=J, M=M, h=h, env=env) + + +class CAM16JMh(LCh): + """CAM16 class (JMh).""" + + BASE = "xyz-d65" + NAME = "cam16-jmh" + SERIALIZE = ("--cam16-jmh",) + CHANNEL_ALIASES = { + "lightness": "j", + "colorfulness": 'm', + "hue": 'h' + } + WHITE = WHITES['2deg']['D65'] + # 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), + Channel("m", 0, 105.0), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "j" + + def radial_name(self) -> str: + """Get radial name.""" + + return "m" + + 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 to_base(self, coords: Vector) -> Vector: + """From CAM16 JMh to XYZ.""" + + return cam_jmh_to_xyz(coords, self.ENV) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ to CAM16 JMh.""" + + return xyz_to_cam_jmh(coords, self.ENV) diff --git a/lib/coloraide/spaces/cam16_jmh.py b/lib/coloraide/spaces/cam16_jmh.py index c1f2879..5741dd9 100644 --- a/lib/coloraide/spaces/cam16_jmh.py +++ b/lib/coloraide/spaces/cam16_jmh.py @@ -1,398 +1,13 @@ """ -CAM16 class (JMh). +Deprecated CAM16 submodule. -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 +Users should import from `coloraide.spaces.cam16` instead. """ -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, CAT16 -from ..channels import Channel, FLG_ANGLE -from .lch import ACHROMATIC_THRESHOLD -from ..types import Vector, VectorLike - -# CAT16 -M16 = CAT16.MATRIX -MI6_INV = alg.inv(M16) - -M1 = [ - [460.0, 451.0, 288.0], - [460.0, -891.0, -261.0], - [460.0, -220.0, -6300.0] -] - -ADAPTED_COEF = 0.42 -ADAPTED_COEF_INV = 1 / ADAPTED_COEF - -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": (20.14, 90.00, 164.25, 237.53, 380.14), - "e": (0.8, 0.7, 1.0, 1.2, 0.8), - "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(H: float) -> float: - """Hue quadrature to hue.""" - - Hp = (H % 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(coords: Vector, fl: float) -> Vector: - """Adapt the coordinates.""" - - adapted = [] - for c in coords: - x = (fl * abs(c) * 0.01) ** ADAPTED_COEF - adapted.append(400 * math.copysign(x, c) / (x + 27.13)) - return adapted - - -def unadapt(adapted: Vector, fl: float) -> Vector: - """Remove adaptation from coordinates.""" - - coords = [] - constant = 100 / fl * (27.13 ** ADAPTED_COEF_INV) - for c in adapted: - cabs = abs(c) - coords.append(math.copysign(constant * alg.spow(cabs / (400 - cabs), ADAPTED_COEF_INV), c)) - return coords - - -class Environment: - """ - Class to calculate and contain any required environmental data (viewing conditions included). - - Usage Guidelines for CIECAM97s (Nathan Moroney) - https://www.researchgate.net/publication/220865484_Usage_guidelines_for_CIECAM97s - - 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`. 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, - 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.discounting = discounting - self.ref_white = util.xy_to_xyz(white) - self.surround = surround - - # 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.matmul(M16, xyz_w, dims=alg.D2_D1) - - # Surround: dark, dim, and average - f, self.c, self.nc = SURROUND[self.surround] - - k = 1 / (5 * self.la + 1) - k4 = k ** 4 - - # Factor of luminance level adaptation - self.fl = (k4 * self.la + 0.1 * (1 - k4) * (1 - k4) * math.pow(5 * self.la, 1 / 3)) - self.fl_root = math.pow(self.fl, 0.25) - - self.n = self.yb / yw - self.z = 1.48 + math.sqrt(self.n) - self.nbb = 0.725 * math.pow(self.n, -0.2) - self.ncb = self.nbb - - # Degree of adaptation calculating if not discounting illuminant (assumed eye is fully adapted) - d = alg.clamp(f * (1 - 1 / 3.6 * math.exp((-self.la - 42) / 92)), 0, 1) if not discounting else 1 - self.d_rgb = [alg.lerp(1, yw / coord, d) for coord in rgb_w] - self.d_rgb_inv = [1 / coord for coord in self.d_rgb] - - # Achromatic response - rgb_cw = alg.multiply(rgb_w, self.d_rgb, dims=alg.D1) - rgb_aw = adapt(rgb_cw, self.fl) - self.a_w = self.nbb * (2 * rgb_aw[0] + rgb_aw[1] + 0.05 * rgb_aw[2]) - - -def cam16_to_xyz_d65( - 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. - - Reverse calculation can actually be obtained from a small subset of the CAM16 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 ((J is not None) ^ (Q is not None)): - raise ValueError("Conversion requires one and only one: 'J' or 'Q'") - - if not ((C is not None) ^ (M is not None) ^ (s is not None)): - raise ValueError("Conversion requires one and only one: 'C', 'M' or 's'") - - # Hue is absolutely required - if not ((h is not None) ^ (H is not None)): - raise ValueError("Conversion requires one and only one: 'h' or 'H'") - - # We need viewing conditions - if env is None: - raise ValueError("No viewing conditions/environment provided") - - # Black - if J == 0.0 or Q == 0.0: - return [0.0, 0.0, 0.0] - - # Break hue into Cartesian components - h_rad = 0.0 - if h is not None: - h_rad = math.radians(h % 360) - elif H is not None: - h_rad = math.radians(inv_hue_quadrature(H)) - cos_h = math.cos(h_rad) - sin_h = math.sin(h_rad) - - # Calculate `J_root` from one of the lightness derived coordinates. - J_root = 0.0 - if J is not None: - J_root = alg.nth_root(J, 2) * 0.1 - elif Q is not None: - J_root = 0.25 * env.c * Q / ((env.a_w + 4) * env.fl_root) - - # Calculate the `t` value from one of the chroma derived coordinates - alpha = 0.0 - if C is not None: - alpha = C / J_root - elif M is not None: - 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.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.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) * 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.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, calc_hue_quadrature: bool = False) -> Vector: - """From XYZ to CAM16.""" - - # Cone response - rgb_a = adapt( - alg.multiply( - alg.matmul(M16, util.scale100(xyzd65), dims=alg.D2_D1), - env.d_rgb, - dims=alg.D1 - ), - env.fl - ) - - # 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) % math.tau - - # Eccentricity - et = 0.25 * (math.cos(h_rad + 2) + 3.8) - - t = ( - 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.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.spow(A / env.a_w, 0.5 * env.c * env.z) - - # Lightness - J = 100 * alg.spow(J_root, 2) - - # Brightness - Q = (4 / env.c * J_root * (env.a_w + 4) * env.fl_root) - - # Chroma - C = alpha * J_root - - # Colorfulness - M = C * env.fl_root - - # Hue - h = util.constrain_hue(math.degrees(h_rad)) - - # Hue quadrature - 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) - - return [J, C, h, s, Q, M, H] - - -def xyz_d65_to_cam16_jmh(xyzd65: Vector, env: Environment) -> Vector: - """XYZ to CAM16 JMh.""" - - cam16 = xyz_d65_to_cam16(xyzd65, env) - J, M, h = cam16[0], cam16[5], cam16[2] - return [J, M, h] - - -def cam16_jmh_to_xyz_d65(jmh: Vector, env: Environment) -> Vector: - """CAM16 JMh to XYZ.""" - - J, M, h = jmh - return cam16_to_xyz_d65(J=J, M=M, h=h, env=env) - - -class CAM16JMh(LChish, Space): - """CAM16 class (JMh).""" - - BASE = "xyz-d65" - NAME = "cam16-jmh" - SERIALIZE = ("--cam16-jmh",) - CHANNEL_ALIASES = { - "lightness": "j", - "colorfulness": 'm', - "hue": 'h' - } - WHITE = WHITES['2deg']['D65'] - # 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), - Channel("m", 0, 105.0), - Channel("h", 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 to_base(self, coords: Vector) -> Vector: - """From CAM16 JMh to XYZ.""" - - return cam16_jmh_to_xyz_d65(coords, self.ENV) - - def from_base(self, coords: Vector) -> Vector: - """From XYZ to CAM16 JMh.""" - - return xyz_d65_to_cam16_jmh(coords, self.ENV) +from .cam16 import * # noqa: F403 +from warnings import warn + +warn( + f'The module {__name__} is deprecated, please use coloraide.spaces.cam16 instead.', + DeprecationWarning, + stacklevel=2 +) diff --git a/lib/coloraide/spaces/cam16_ucs.py b/lib/coloraide/spaces/cam16_ucs.py index d583085..5a00097 100644 --- a/lib/coloraide/spaces/cam16_ucs.py +++ b/lib/coloraide/spaces/cam16_ucs.py @@ -1,5 +1,5 @@ """ -CAM 16 UCS. +CAM16 UCS. https://observablehq.com/@jrus/cam16 https://arxiv.org/abs/1802.06067 @@ -7,9 +7,9 @@ """ from __future__ import annotations import math -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 .. import algebra as alg +from .cam16 import CAM16JMh, xyz_to_cam, cam_to_xyz +from .lab import Lab from ..cat import WHITES from .. import util from ..channels import Channel, FLG_MIRROR_PERCENT @@ -22,7 +22,10 @@ } -def cam16_jmh_to_cam16_ucs(jmh: Vector, model: str, env: Environment) -> Vector: +def cam_jmh_to_cam_ucs( + jmh: Vector, + model: str +) -> Vector: """ CAM16 (Jab) to CAM16 UCS (Jab). @@ -33,12 +36,9 @@ def cam16_jmh_to_cam16_ucs(jmh: Vector, model: str, env: Environment) -> Vector: 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] + if M == 0.0: + return [0.0, 0.0, 00] + J = alg.EPS c1, c2 = COEFFICENTS[model][1:] @@ -57,7 +57,7 @@ def cam16_jmh_to_cam16_ucs(jmh: Vector, model: str, env: Environment) -> Vector: ] -def cam16_ucs_to_cam16_jmh(ucs: Vector, model: str) -> Vector: +def cam_ucs_to_cam_jmh(ucs: Vector, model: str) -> Vector: """ CAM16 UCS (Jab) to CAM16 (Jab). @@ -68,7 +68,9 @@ def cam16_ucs_to_cam16_jmh(ucs: Vector, model: str) -> Vector: J, a, b = ucs if J == 0.0: - return [0.0, 0.0, 0.0] + if a == b == 0.0: + return [0.0, 0.0, 00] + J = alg.EPS c1, c2 = COEFFICENTS[model][1:] @@ -84,7 +86,7 @@ def cam16_ucs_to_cam16_jmh(ucs: Vector, model: str) -> Vector: ] -class CAM16UCS(Labish, Space): +class CAM16UCS(Lab): """CAM16 UCS (Jab) class.""" BASE = "cam16-jmh" @@ -103,21 +105,31 @@ class CAM16UCS(Labish, Space): # Use the same environment as CAM16JMh ENV = CAM16JMh.ENV + def lightness_name(self) -> str: + """Get lightness name.""" + + return "j" + def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" - j, m = cam16_ucs_to_cam16_jmh(coords, self.MODEL)[:-1] - return j == 0 or abs(m) < ACHROMATIC_THRESHOLD + m = cam_ucs_to_cam_jmh(coords, self.MODEL)[1] + return abs(m) < self.achromatic_threshold def to_base(self, coords: Vector) -> Vector: """To CAM16 JMh from CAM16.""" - return cam16_ucs_to_cam16_jmh(coords, self.MODEL) + return cam_ucs_to_cam_jmh(coords, self.MODEL) def from_base(self, coords: Vector) -> Vector: """From CAM16 JMh to CAM16.""" - return cam16_jmh_to_cam16_ucs(coords, self.MODEL, self.ENV) + # Account for negative colorfulness by reconverting as this can many times corrects the problem + if coords[1] < 0: + cam16 = xyz_to_cam(cam_to_xyz(J=coords[0], M=coords[1], h=coords[2], env=self.ENV), env=self.ENV) + coords = [cam16[0], cam16[5], cam16[2]] + + return cam_jmh_to_cam_ucs(coords, self.MODEL) class CAM16LCD(CAM16UCS): diff --git a/lib/coloraide/spaces/cmy.py b/lib/coloraide/spaces/cmy.py index 0101502..8629d11 100644 --- a/lib/coloraide/spaces/cmy.py +++ b/lib/coloraide/spaces/cmy.py @@ -1,6 +1,8 @@ """Uncalibrated, naive CMY color space.""" from __future__ import annotations -from ..spaces import Regular, Space +from .. import util +from ..spaces import Prism, Space +from .srgb import sRGB from ..channels import Channel from ..cat import WHITES from ..types import Vector @@ -20,7 +22,7 @@ def cmy_to_srgb(cmy: Vector) -> Vector: return [1 - c for c in cmy] -class CMY(Regular, Space): +class CMY(Prism, Space): """The CMY color class.""" BASE = "srgb" @@ -37,13 +39,19 @@ class CMY(Regular, Space): "yellow": 'y' } WHITE = WHITES['2deg']['D65'] + SUBTRACTIVE = True + + def linear(self) -> str: + """Linear.""" + + return sRGB.BASE def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" black = [1, 1, 1] for x in alg.vcross(coords, black): - if not math.isclose(0.0, x, abs_tol=1e-4): + if not math.isclose(0.0, x, abs_tol=util.ACHROMATIC_THRESHOLD_SM): return False return True diff --git a/lib/coloraide/spaces/cmyk.py b/lib/coloraide/spaces/cmyk.py index 066f6b7..bf441f6 100644 --- a/lib/coloraide/spaces/cmyk.py +++ b/lib/coloraide/spaces/cmyk.py @@ -4,6 +4,7 @@ https://www.w3.org/TR/css-color-5/#cmyk-rgb """ from __future__ import annotations +from .. import util from ..spaces import Space from ..channels import Channel from ..cat import WHITES @@ -12,35 +13,28 @@ import math -def srgb_to_cmyk(rgb: Vector) -> Vector: +def srgb_to_cmyk(cmy: Vector) -> Vector: """Convert sRGB to CMYK.""" - k = 1.0 - max(rgb) - c = m = y = 0.0 - if k != 1: - r, g, b = rgb - c = (1.0 - r - k) / (1.0 - k) - m = (1.0 - g - k) / (1.0 - k) - y = (1.0 - b - k) / (1.0 - k) - - return [c, m, y, k] + k = min(cmy) + if k == 1: + return [0.0, 0.0, 0.0, k] + cmyk = [(v - k) / (1.0 - k) for v in cmy] + cmyk.append(k) + return cmyk def cmyk_to_srgb(cmyk: Vector) -> Vector: """Convert CMYK to sRGB.""" - c, m, y, k = cmyk - return [ - 1.0 - min(1.0, c * (1.0 - k) + k), - 1.0 - min(1.0, m * (1.0 - k) + k), - 1.0 - min(1.0, y * (1.0 - k) + k) - ] + k = cmyk[-1] + return [v * (1.0 - k) + k for v in cmyk[:-1]] class CMYK(Space): """The CMYK color class.""" - BASE = "srgb" + BASE = "cmy" NAME = "cmyk" SERIALIZE = ("--cmyk",) # type: tuple[str, ...] CHANNELS = ( @@ -56,16 +50,19 @@ class CMYK(Space): "black": 'k' } WHITE = WHITES['2deg']['D65'] + SUBTRACTIVE = True + GAMUT_CHECK = 'cmy' + CLIP_SPACE = 'cmyk' def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" - if math.isclose(1.0, coords[-1], abs_tol=1e-4): + if math.isclose(1.0, coords[-1], abs_tol=util.ACHROMATIC_THRESHOLD_SM): return True black = [1, 1, 1] for x in alg.vcross(coords[:-1], black): - if not math.isclose(0.0, x, abs_tol=1e-5): + if not math.isclose(0.0, x, abs_tol=util.ACHROMATIC_THRESHOLD_SM): return False return True diff --git a/lib/coloraide/spaces/cubehelix.py b/lib/coloraide/spaces/cubehelix.py index ad0a300..046fc7b 100644 --- a/lib/coloraide/spaces/cubehelix.py +++ b/lib/coloraide/spaces/cubehelix.py @@ -24,7 +24,7 @@ THIS SOFTWARE. """ from __future__ import annotations -from ..spaces import Space, HSLish +from . hsl import HSL from ..cat import WHITES from ..channels import Channel, FLG_ANGLE import math @@ -72,7 +72,7 @@ def cubehelix_to_srgb(coords: Vector) -> Vector: ] -class Cubehelix(HSLish, Space): +class Cubehelix(HSL): """Cubehelix class.""" BASE = 'srgb' @@ -103,7 +103,7 @@ def normalize(self, coords: Vector) -> Vector: def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" - return abs(coords[1]) < 1e-4 or coords[2] > (1 - 1e-7) or coords[2] < 1e-08 + return abs(coords[1]) < self.achromatic_threshold or coords[2] > (1 - 1e-7) or coords[2] < 1e-08 def to_base(self, coords: Vector) -> Vector: """To LChuv from HSLuv.""" diff --git a/lib/coloraide/spaces/display_p3.py b/lib/coloraide/spaces/display_p3.py index 42fe41b..a9c44b4 100644 --- a/lib/coloraide/spaces/display_p3.py +++ b/lib/coloraide/spaces/display_p3.py @@ -1,7 +1,7 @@ """Display-p3 color class.""" from __future__ import annotations from .srgb_linear import sRGBLinear -from .srgb import lin_srgb, gam_srgb +from .srgb import inverse_eotf_srgb, eotf_srgb from ..types import Vector @@ -19,9 +19,9 @@ def linear(self) -> str: def to_base(self, coords: Vector) -> Vector: """To XYZ from Display P3.""" - return lin_srgb(coords) + return eotf_srgb(coords) def from_base(self, coords: Vector) -> Vector: """From XYZ to Display P3.""" - return gam_srgb(coords) + return inverse_eotf_srgb(coords) diff --git a/lib/coloraide/spaces/display_p3_linear.py b/lib/coloraide/spaces/display_p3_linear.py index 90f1679..0742700 100644 --- a/lib/coloraide/spaces/display_p3_linear.py +++ b/lib/coloraide/spaces/display_p3_linear.py @@ -25,13 +25,13 @@ def lin_p3_to_xyz(rgb: Vector) -> Vector: """ # 0 was computed as -3.972075516933488e-17 - return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul_x3(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_p3(xyz: Vector) -> Vector: """Convert XYZ to linear-light P3.""" - return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul_x3(XYZ_TO_RGB, xyz, dims=alg.D2_D1) class DisplayP3Linear(sRGBLinear): @@ -39,7 +39,7 @@ class DisplayP3Linear(sRGBLinear): BASE = "xyz-d65" NAME = "display-p3-linear" - SERIALIZE = ('--display-p3-linear',) + SERIALIZE = ('display-p3-linear', '--display-p3-linear') 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 c86d2c5..005eef2 100644 --- a/lib/coloraide/spaces/hct.py +++ b/lib/coloraide/spaces/hct.py @@ -5,11 +5,6 @@ We simply, as described, create a color space with CIELAB L* and CAM16's C and h components. 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 -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 sRGB and SDR range in the Material library, but we do not have such restrictions. @@ -43,12 +38,11 @@ """ from __future__ import annotations from .. import algebra as alg -from ..spaces import Space, LChish +from .lch import LCh 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 import Environment, cam_to_xyz, xyz_to_cam from .lab import EPSILON, KAPPA, KE -from .lch import ACHROMATIC_THRESHOLD from ..types import Vector import math @@ -82,8 +76,7 @@ def hct_to_xyz(coords: Vector, env: Environment) -> Vector: h, c, t = coords[:] - # Shortcut out for black - if t == 0: + if t == 0 and c == 0: return [0.0, 0.0, 0.0] # Calculate the Y we need to target @@ -91,34 +84,32 @@ def hct_to_xyz(coords: Vector, env: Environment) -> Vector: # Try to start with a reasonable initial guess for J # Calculated by curve fitting J vs T. - if t > 0: - j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233 + if t >= 0: + j = 0.003790578348640494 * t * t + 0.6089841908066893 * t + 0.9154856839591797 else: - j = 9.514440756550361e-06 * t ** 2 + 0.08693057439788597 * t -21.928975842194614 + j = 9.514281401058887e-06 * t * t + 0.08693011228986187 * t - 21.92910930537688 - # 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 + epsilon = 1e-12 - attempt = 0 + maxiter = 16 last = math.inf - best = j + best = xyz = [0.0] * 3 # 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) + for _ in range(maxiter): + prev = j + xyz = cam_to_xyz(J=j, C=c, h=h, env=env) # If we are within range, return XYZ # If we are closer than last time, save the values - delta = abs(xyz[1] - y) + f0 = xyz[1] - y + delta = abs(f0) + + if delta < epsilon: + return xyz + if delta < last: - if delta <= threshold: - return xyz - best = j + best = xyz last = delta # ``` @@ -127,26 +118,34 @@ def hct_to_xyz(coords: Vector, env: Environment) -> Vector: # f(j_root) = Y = y / 100 # f(j) = (y ** 2) / j - 1 # f'(j) = (2 * y) / j + # f'(j) = dx + # j = j - f0 / dx # ``` - j = j - (xyz[1] - y) * j / (2 * xyz[1]) - attempt += 1 + # Newton: 2nd order convergence + # `dx` fraction is flipped so we can multiply by the derivative instead of divide + j -= f0 * alg.zdiv(j, 2 * xyz[1]) + + # If J is zero, the next round will yield zero, so quit + if j == 0 or abs(prev - j) < epsilon: # pragma: no cover + break + + # ``` + # print('FAIL:', [h, c, t], xyz[1], y) + # ``` - # We could not acquire the precision we desired, return our closest attempt. - return cam16_to_xyz_d65(J=best, C=c, h=h, env=env) # pragma: no cover + return best def xyz_to_hct(coords: Vector, env: Environment) -> Vector: """Convert XYZ to HCT.""" 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] + c, h = xyz_to_cam(coords, env)[1:3] return [h, c, t] -class HCT(LChish, Space): +class HCT(LCh): """HCT class.""" BASE = "xyz-d65" @@ -178,6 +177,11 @@ class HCT(LChish, Space): Channel("t", 0.0, 100.0) ) + def lightness_name(self) -> str: + """Get lightness name.""" + + return "t" + def normalize(self, coords: Vector) -> Vector: """Normalize.""" @@ -186,13 +190,7 @@ def normalize(self, coords: Vector) -> Vector: coords[0] %= 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[2] == 0 or abs(coords[1]) < ACHROMATIC_THRESHOLD - - def names(self) -> tuple[str, ...]: + def names(self) -> tuple[Channel, ...]: """Return LCh-ish names in the order L C h.""" channels = self.channels diff --git a/lib/coloraide/spaces/hellwig.py b/lib/coloraide/spaces/hellwig.py new file mode 100644 index 0000000..7641b10 --- /dev/null +++ b/lib/coloraide/spaces/hellwig.py @@ -0,0 +1,387 @@ +""" +Hellwig 2022: CAM16 class (JMh) with corrections. + +CAM16 +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 + +CAM16 Corrections: Hellwig and Fairchild +http://markfairchild.org/PDFs/PAP45.pdf + +Helmholtz Kohlrausch Effect extension: Hellwig, Stolitzka, and Fairchild +https://www.scribd.com/document/788387893/Color-Research-Application-2022-Hellwig-Extending-CIECAM02-and-CAM16-for-the-Helmholtz-Kohlrausch-effect +""" +from __future__ import annotations +import math +from .cam16 import ( + M16, + M16_INV, + M1, + adapt, + unadapt, + hue_quadrature, + inv_hue_quadrature +) +from .cam16 import Environment as _Environment +from .lch import LCh +from .. import util +from .. import algebra as alg +from ..cat import WHITES +from ..channels import Channel, FLG_ANGLE +from ..types import Vector, VectorLike + + +def hue_angle_dependency(h: float) -> float: + """Calculate the hue angle dependency for CAM16.""" + + return ( + -0.160 * math.cos(h) + + 0.132 * math.cos(2 * h) + - 0.405 * math.sin(h) + + 0.080 * math.sin(2 * h) + + 0.792 + ) + + +def eccentricity(h: float) -> float: + """Calculate eccentricity.""" + + # Eccentricity + h2 = 2 * h + h3 = 3 * h + h4 = 4 * h + return ( + -0.0582 * math.cos(h) - 0.0258 * math.cos(h2) + - 0.1347 * math.cos(h3) + 0.0289 * math.cos(h4) + -0.1475 * math.sin(h) - 0.0308 * math.sin(h2) + + 0.0385 * math.sin(h3) + 0.0096 * math.sin(h4) + 1 + ) + + +class Environment(_Environment): + """ + Class to calculate and contain any required environmental data (viewing conditions included). + + Usage Guidelines for CIECAM97s (Nathan Moroney) + https://www.researchgate.net/publication/220865484_Usage_guidelines_for_CIECAM97s + + `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 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. + + `hk`: Whether to adjust lightness for the Helmholtz-Kohlrausch effect. + """ + + def __init__( + self, + *, + white: VectorLike, + adapting_luminance: float, + background_luminance: float, + surround: str, + discounting: bool, + hk: bool, + ) -> None: + """ + 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. + """ + + super().__init__( + white=white, + adapting_luminance=adapting_luminance, + background_luminance=background_luminance, + surround=surround, + discounting=discounting + ) + self.hk = hk + + def calculate_adaptation(self, xyz_w: Vector) -> None: + """Calculate the adaptation of the reference point and related variables.""" + + # Cone response for reference white + self.rgb_w = alg.matmul_x3(M16, xyz_w, dims=alg.D2_D1) + + self.d_rgb = [alg.lerp(1, self.yw / coord, self.d) for coord in self.rgb_w] + self.d_rgb_inv = [1 / coord for coord in self.d_rgb] + + # Achromatic response + self.rgb_cw = alg.multiply_x3(self.rgb_w, self.d_rgb, dims=alg.D1) + rgb_aw = adapt(self.rgb_cw, self.fl) + self.a_w = (2 * rgb_aw[0] + rgb_aw[1] + 0.05 * rgb_aw[2]) + + +def cam_to_xyz( + 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. + + Reverse calculation can actually be obtained from a small subset of the CAM16 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 ((J is not None) ^ (Q is not None)): + raise ValueError("Conversion requires one and only one: 'J' or 'Q'") + + if not ((C is not None) ^ (M is not None) ^ (s is not None)): + raise ValueError("Conversion requires one and only one: 'C', 'M' or 's'") + + # Hue is absolutely required + if not ((h is not None) ^ (H is not None)): + raise ValueError("Conversion requires one and only one: 'h' or 'H'") + + # We need viewing conditions + if env is None: + raise ValueError("No viewing conditions/environment provided") + + # Shortcut out if black? + if J == 0.0 or Q == 0: + if not any((C, M, s)): + return [0.0, 0.0, 0.0] + + # Break hue into Cartesian components + h_rad = 0.0 + if h is not None: + h_rad = math.radians(h % 360) + elif H is not None: + h_rad = math.radians(inv_hue_quadrature(H)) + + # Calculate `J_root` from one of the lightness derived coordinates. + if M is not None: + C = M * 35 / env.a_w + elif C is not None: + M = (C * env.a_w) / 35 + + if env.hk and Q is not None: + J = (50 * env.c * Q) / env.a_w + + if J is not None: + if env.hk: + if C is None: + raise ValueError('C or M is required to resolve J and Q when H-K effect is enabled') + J -= hue_angle_dependency(h_rad) * alg.spow(C, 0.587) + Q = (2 / env.c) * (J / 100) * env.a_w + elif Q is not None: + J = (50 * env.c * Q) / env.a_w + + if s is not None: + M = Q * (s / 100) # type: ignore[operator] + + # Eccentricity + et = eccentricity(h_rad) + + # Achromatic response + A = env.a_w * alg.nth_root(J / 100, env.c * env.z) # type: ignore[operator] + + # Calculate red-green and yellow-blue components + cos_h = math.cos(h_rad) + sin_h = math.sin(h_rad) + p1 = 43 * env.nc * et + p2 = A + r = M / p1 # type: ignore[operator] + a = r * cos_h + b = r * sin_h + + # Calculate back from cone response to XYZ + rgb_a = alg.multiply_x3(alg.matmul_x3(M1, [p2, a, b], dims=alg.D2_D1), 1 / 1403, dims=alg.D1_SC) + rgb_c = unadapt(rgb_a, env.fl) + return util.scale1(alg.matmul_x3(M16_INV, alg.multiply_x3(rgb_c, env.d_rgb_inv, dims=alg.D1), dims=alg.D2_D1)) + + +def xyz_to_cam(xyz: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector: + """From XYZ to CAM16.""" + + # Calculate cone response + rgb_c = alg.multiply_x3( + alg.matmul_x3(M16, util.scale100(xyz), dims=alg.D2_D1), + env.d_rgb, + dims=alg.D1 + ) + rgb_a = adapt(rgb_c, env.fl) + + # Calculate red-green and yellow components and resultant hue + p2 = 2 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2] + 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) % math.tau + + # Eccentricity + et = eccentricity(h_rad) + + # Achromatic response + A = p2 + + # Lightness + J = 100 * alg.spow(A / env.a_w, env.c * env.z) + + # Brightness + Q = (2 / env.c) * (J / 100) * env.a_w + + # Colorfulness + M = 43 * env.nc * et * math.hypot(a, b) + + # Chroma + C = 35 * (M / env.a_w) + + # Saturation + s = 100 * alg.zdiv(M, Q) + + # Hue + h = util.constrain_hue(math.degrees(h_rad)) + + # Hue quadrature + H = hue_quadrature(h) if calc_hue_quadrature else alg.NaN + + # Adjust lightness and brightness for the Helmholtz-Kohlrausch effect + if env.hk: + J += hue_angle_dependency(h_rad) * alg.spow(C, 0.587) + Q = (2 / env.c) * (J / 100) * env.a_w + + return [J, C, h, s, Q, M, H] + + +def xyz_to_cam_jmh(xyz: Vector, env: Environment) -> Vector: + """XYZ to CAM16 JMh with corrections.""" + + cam16 = xyz_to_cam(xyz, env) + J, M, h = cam16[0], cam16[5], cam16[2] + return [J, M, h] + + +def cam_jmh_to_xyz(jmh: Vector, env: Environment) -> Vector: + """CAM16 JMh with corrections to XYZ.""" + + J, M, h = jmh + return cam_to_xyz(J=J, M=M, h=h, env=env) + + +class HellwigJMh(LCh): + """CAM16 class (JMh) with corrections.""" + + BASE = "xyz-d65" + NAME = "hellwig-jmh" + SERIALIZE = ("--hellwig-jmh",) + CHANNEL_ALIASES = { + "lightness": "j", + "colorfulness": 'm', + "hue": 'h' + } + WHITE = WHITES['2deg']['D65'] + HK = False + + # 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, + # Account for Helmholtz-Kohlrausch effect + hk=False + ) + CHANNELS = ( + Channel("j", 0.0, 100.0), + Channel("m", 0, 70.0), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "j" + + def radial_name(self) -> str: + """Get radial name.""" + + return "m" + + 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 to_base(self, coords: Vector) -> Vector: + """From CAM16 JMh to XYZ.""" + + return cam_jmh_to_xyz(coords, self.ENV) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ to CAM16 JMh.""" + + return xyz_to_cam_jmh(coords, self.ENV) + + +class HellwigHKJMh(HellwigJMh): + """CAM16 class (JMh) with corrections and accounting for the Helmholtz-Kohlrausch effect.""" + + NAME = "hellwig-hk-jmh" + SERIALIZE = ("--hellwig-hk-jmh",) + WHITE = WHITES['2deg']['D65'] + + # 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, + # Account for Helmholtz-Kohlrausch effect + hk=True + ) + CHANNELS = ( + Channel("j", 0.0, 101.56018891418564), + Channel("m", 0, 70.0), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) diff --git a/lib/coloraide/spaces/hpluv.py b/lib/coloraide/spaces/hpluv.py index c002928..0de7f3f 100644 --- a/lib/coloraide/spaces/hpluv.py +++ b/lib/coloraide/spaces/hpluv.py @@ -25,7 +25,7 @@ SOFTWARE. """ from __future__ import annotations -from ..spaces import Space, HSLish +from .hsl import HSL from ..cat import WHITES from ..channels import Channel, FLG_ANGLE from .lab import EPSILON, KAPPA @@ -103,7 +103,7 @@ def luv_to_hpluv(luv: Vector) -> Vector: return [util.constrain_hue(h), s, l] -class HPLuv(HSLish, Space): +class HPLuv(HSL): """HPLuv class.""" BASE = 'luv' @@ -120,6 +120,7 @@ class HPLuv(HSLish, Space): "lightness": "l" } WHITE = WHITES['2deg']['D65'] + GAMUT_CHECK = None def normalize(self, coords: Vector) -> Vector: """Normalize coordinates.""" @@ -132,7 +133,12 @@ def normalize(self, coords: Vector) -> Vector: def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" - return abs(coords[1]) < 1e-4 or coords[2] > (100 - 1e-7) or coords[2] < 1e-08 + return abs(coords[1]) < self.achromatic_threshold or coords[2] > (100 - 1e-7) or coords[2] < 1e-08 + + def radial_name(self) -> str: + """Radial name.""" + + return "p" def to_base(self, coords: Vector) -> Vector: """To LChuv from HPLuv.""" @@ -143,8 +149,3 @@ def from_base(self, coords: Vector) -> Vector: """From LChuv to HPLuv.""" 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 af224e9..4618b82 100644 --- a/lib/coloraide/spaces/hsi.py +++ b/lib/coloraide/spaces/hsi.py @@ -85,6 +85,11 @@ class HSI(HSV): GAMUT_CHECK = "srgb" CLIP_SPACE = None + def lightness_name(self) -> str: + """Get lightness name.""" + + return "i" + 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 0cb4f1d..c267018 100644 --- a/lib/coloraide/spaces/hsl/__init__.py +++ b/lib/coloraide/spaces/hsl/__init__.py @@ -1,14 +1,22 @@ """HSL class.""" from __future__ import annotations +from ... import algebra as alg from ...spaces import HSLish, Space from ...cat import WHITES from ...channels import Channel, FLG_ANGLE from ... import util from ...types import Vector +from typing import Any def srgb_to_hsl(rgb: Vector) -> Vector: - """Convert sRGB to HSL.""" + """ + Convert sRGB to HSL. + + https://en.wikipedia.org/wiki/HSL_and_HSV#Hue_and_chroma + https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation + https://en.wikipedia.org/wiki/HSL_and_HSV#Lightness + """ r, g, b = rgb mx = max(rgb) @@ -44,11 +52,12 @@ def hsl_to_srgb(hsl: Vector) -> Vector: """ h, s, l = hsl - h = util.constrain_hue(h) + h = util.constrain_hue(h) / 30 def f(n: int) -> float: """Calculate the channels.""" - k = (n + h / 30) % 12 + + k = (n + h) % 12 a = s * min(l, 1 - l) return l - a * max(-1, min(k - 3, 9 - k, 1)) @@ -75,6 +84,13 @@ class HSL(HSLish, Space): GAMUT_CHECK = "srgb" # type: str | None CLIP_SPACE = "hsl" # type: str | None + def __init__(self, **kwargs: Any): + """Initialize.""" + + super().__init__(**kwargs) + order = alg.order(round(self.channels[self.indexes()[2]].high, 5)) + self.achromatic_threshold = (1 * 10.0 ** order) / 1_000_000 + def normalize(self, coords: Vector) -> Vector: """Normalize coordinates.""" @@ -87,7 +103,11 @@ def normalize(self, coords: Vector) -> Vector: 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 + return ( + abs(coords[1]) < self.achromatic_threshold or + coords[2] == 0.0 or + abs(1 - coords[2]) < self.achromatic_threshold + ) def to_base(self, coords: Vector) -> Vector: """To sRGB from HSL.""" diff --git a/lib/coloraide/spaces/hsl/css.py b/lib/coloraide/spaces/hsl/css.py index 6281d2d..3a2cb3f 100644 --- a/lib/coloraide/spaces/hsl/css.py +++ b/lib/coloraide/spaces/hsl/css.py @@ -4,9 +4,9 @@ from ...css import parse from ...css import serialize from ...types import Vector -from typing import Any, TYPE_CHECKING, Sequence +from typing import Any, Sequence, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ...color import Color @@ -18,32 +18,29 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool | Sequence[bool] | None = None, + percent: bool | Sequence[bool] = False, comma: bool = False, **kwargs: Any ) -> str: """Convert to CSS.""" - if percent is None: - if not color: + if comma: + if isinstance(percent, bool): percent = True else: - percent = False - elif isinstance(percent, bool): - if comma: - percent = True - elif comma: - percent = [False, True, True] + list(percent[3:4]) + percent = [False, True, True, *percent[3:4]] return serialize.serialize_css( parent, func='hsl', alpha=alpha, precision=precision, + rounding=rounding, fit=fit, none=none, color=color, diff --git a/lib/coloraide/spaces/hsluv.py b/lib/coloraide/spaces/hsluv.py index b8c259e..8a73042 100644 --- a/lib/coloraide/spaces/hsluv.py +++ b/lib/coloraide/spaces/hsluv.py @@ -25,9 +25,9 @@ SOFTWARE. """ from __future__ import annotations -from ..spaces import Space, HSLish from ..cat import WHITES from ..channels import Channel, FLG_ANGLE +from .hsl import HSL from .lab import EPSILON, KAPPA from .srgb_linear import XYZ_TO_RGB import math @@ -106,7 +106,7 @@ def luv_to_hsluv(luv: Vector) -> Vector: return [util.constrain_hue(h), s, l] -class HSLuv(HSLish, Space): +class HSLuv(HSL): """HSLuv class.""" BASE = 'luv' @@ -137,7 +137,7 @@ def normalize(self, coords: Vector) -> Vector: def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" - return abs(coords[1]) < 1e-4 or coords[2] > (100 - 1e-7) or coords[2] < 1e-08 + return abs(coords[1]) < self.achromatic_threshold or coords[2] > (100 - 1e-7) or coords[2] < 1e-08 def to_base(self, coords: Vector) -> Vector: """To LChuv from HSLuv.""" diff --git a/lib/coloraide/spaces/hsv.py b/lib/coloraide/spaces/hsv.py index 440276d..e5b3c34 100644 --- a/lib/coloraide/spaces/hsv.py +++ b/lib/coloraide/spaces/hsv.py @@ -1,37 +1,60 @@ """HSV class.""" from __future__ import annotations +from .. import algebra as alg 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 +from typing import Any def hsv_to_srgb(hsv: Vector) -> Vector: """ - HSV to HSL. + Convert HSV to sRGB. - https://en.wikipedia.org/wiki/HSL_and_HSV#Interconversion + https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative """ h, s, v = hsv - 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) + h = util.constrain_hue(h) / 60 - return hsl_to_srgb([h, s, l]) + def f(n: int) -> float: + """Calculate the channels.""" + k = (n + h) % 6 + return v - v * s * max(0, min([k, 4 - k, 1])) -def srgb_to_hsv(srgb: Vector) -> Vector: + return [f(5), f(3), f(1)] + + +def srgb_to_hsv(rgb: Vector) -> Vector: """ - HSL to HSV. + Convert sRGB to HSV. - https://en.wikipedia.org/wiki/HSL_and_HSV#Interconversion + https://en.wikipedia.org/wiki/HSL_and_HSV#Hue_and_chroma + https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation + https://en.wikipedia.org/wiki/HSL_and_HSV#Lightness """ - 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) + r, g, b = rgb + v = max(rgb) + mn = min(rgb) + h = 0.0 + s = 0.0 + c = v - mn + + if c != 0.0: + if v == r: + h = (g - b) / c + elif v == g: + h = (b - r) / c + 2.0 + else: + h = (r - g) / c + 4.0 + h *= 60.0 + + if v: + s = c / v return [util.constrain_hue(h), s, v] @@ -56,6 +79,18 @@ class HSV(HSVish, Space): CLIP_SPACE = "hsv" # type: str | None WHITE = WHITES['2deg']['D65'] + def __init__(self, **kwargs: Any): + """Initialize.""" + + super().__init__(**kwargs) + order = alg.order(round(self.channels[self.indexes()[2]].high, 5)) + self.achromatic_threshold = (1 * 10.0 ** order) / 1_000_000 + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "v" + def normalize(self, coords: Vector) -> Vector: """Normalize coordinates.""" @@ -67,7 +102,7 @@ def normalize(self, coords: Vector) -> Vector: def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" - return abs(coords[1]) < 1e-5 or coords[2] == 0.0 + return abs(coords[1]) < self.achromatic_threshold or coords[2] == 0.0 def to_base(self, coords: Vector) -> Vector: """To HSL from HSV.""" diff --git a/lib/coloraide/spaces/hunter_lab.py b/lib/coloraide/spaces/hunter_lab.py index 4bae3f5..4b5a02d 100644 --- a/lib/coloraide/spaces/hunter_lab.py +++ b/lib/coloraide/spaces/hunter_lab.py @@ -23,10 +23,10 @@ def xyz_to_hlab(xyz: Vector, white: VectorLike) -> Vector: """Convert XYZ to Hunter Lab.""" - xn, yn, zn = alg.multiply(util.xy_to_xyz(white), 100, dims=alg.D1_SC) + xn, yn, zn = alg.multiply_x3(util.xy_to_xyz(white), 100, dims=alg.D1_SC) ka = CKA * alg.nth_root(xn / CXN, 2) kb = CKB * alg.nth_root(zn / CZN, 2) - x, y, z = alg.multiply(xyz, 100, dims=alg.D1_SC) + x, y, z = alg.multiply_x3(xyz, 100, dims=alg.D1_SC) l = alg.nth_root(y / yn, 2) a = b = 0.0 if l != 0: @@ -38,7 +38,7 @@ def xyz_to_hlab(xyz: Vector, white: VectorLike) -> Vector: def hlab_to_xyz(hlab: Vector, white: VectorLike) -> Vector: """Convert Hunter Lab to XYZ.""" - xn, yn, zn = alg.multiply(util.xy_to_xyz(white), 100, dims=alg.D1_SC) + xn, yn, zn = alg.multiply_x3(util.xy_to_xyz(white), 100, dims=alg.D1_SC) ka = CKA * alg.nth_root(xn / CXN, 2) kb = CKB * alg.nth_root(zn / CZN, 2) l, a, b = hlab @@ -46,7 +46,7 @@ def hlab_to_xyz(hlab: Vector, white: VectorLike) -> Vector: y = (l ** 2) * yn x = (((a * l) / ka) + (y / yn)) * xn z = (((b * l) / kb) - (y / yn)) * -zn - return alg.multiply([x, y, z], 0.01, dims=alg.D1_SC) + return alg.multiply_x3([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 978801d..1b36d01 100644 --- a/lib/coloraide/spaces/hwb/__init__.py +++ b/lib/coloraide/spaces/hwb/__init__.py @@ -1,31 +1,38 @@ -"""HWB class.""" +""" +HWB class. + +http://alvyray.com/Papers/CG/HWB_JGTv208.pdf +""" from __future__ import annotations from ...spaces import Space, HWBish -from ..hsl import srgb_to_hsl, hsl_to_srgb +from ... import util from ...cat import WHITES from ...channels import Channel, FLG_ANGLE from ...types import Vector -def srgb_to_hwb(srgb: Vector) -> Vector: - """HWB to sRGB.""" +def hsv_to_hwb(hsv: Vector) -> Vector: + """HSV to HWB.""" - return [srgb_to_hsl(srgb)[0], min(srgb), 1 - max(srgb)] + h, s, v = hsv + return [util.constrain_hue(h), (1 - s) * v, 1 - v] -def hwb_to_srgb(hwb: Vector) -> Vector: - """HWB to sRGB.""" +def hwb_to_hsv(hwb: Vector) -> Vector: + """HWB to HSV.""" h, w, b = hwb - wb_sum = w + b - wb_factor = 1 - w - b - return [w / wb_sum] * 3 if wb_sum >= 1 else [c * wb_factor + w for c in hsl_to_srgb([h, 1, 0.5])] + wb = w + b + if wb >= 1: + return [util.constrain_hue(h), 0, w / wb] + v = 1 - b + return [util.constrain_hue(h), 1 - w / v if v else 1, v] class HWB(HWBish, Space): """HWB class.""" - BASE = "srgb" + BASE = "hsv" NAME = "hwb" SERIALIZE = ("--hwb",) CHANNELS = ( @@ -47,11 +54,11 @@ def is_achromatic(self, coords: Vector) -> bool: return (coords[1] + coords[2]) >= (1 - 1e-07) def to_base(self, coords: Vector) -> Vector: - """To sRGB from HWB.""" + """To HSV from HWB.""" - return hwb_to_srgb(coords) + return hwb_to_hsv(coords) def from_base(self, coords: Vector) -> Vector: - """From sRGB to HWB.""" + """From HSV to HWB.""" - return srgb_to_hwb(coords) + return hsv_to_hwb(coords) diff --git a/lib/coloraide/spaces/hwb/css.py b/lib/coloraide/spaces/hwb/css.py index eca92d9..2307bc8 100644 --- a/lib/coloraide/spaces/hwb/css.py +++ b/lib/coloraide/spaces/hwb/css.py @@ -4,9 +4,9 @@ from ...css import parse from ...css import serialize from ...types import Vector -from typing import Any, TYPE_CHECKING, Sequence +from typing import Any, Sequence, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ...color import Color @@ -18,23 +18,22 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool | Sequence[bool] | None = None, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS.""" - if percent is None: - percent = False if color else True - return serialize.serialize_css( parent, func='hwb', alpha=alpha, precision=precision, + rounding=rounding, fit=fit, none=none, color=color, diff --git a/lib/coloraide/spaces/ictcp.py b/lib/coloraide/spaces/ictcp/__init__.py similarity index 75% rename from lib/coloraide/spaces/ictcp.py rename to lib/coloraide/spaces/ictcp/__init__.py index a1b1d64..5061761 100644 --- a/lib/coloraide/spaces/ictcp.py +++ b/lib/coloraide/spaces/ictcp/__init__.py @@ -4,12 +4,12 @@ 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 ..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 # All PQ Values are equivalent to defaults as stated in link below: # https://en.wikipedia.org/wiki/High-dynamic-range_video#Perceptual_quantizer @@ -57,13 +57,13 @@ def ictcp_to_xyz_d65(ictcp: Vector) -> Vector: """From ICtCp to XYZ.""" # Convert to LMS prime - pqlms = alg.matmul(ictcp_to_lms_p_mi, ictcp, dims=alg.D2_D1) + pqlms = alg.matmul_x3(ictcp_to_lms_p_mi, ictcp, dims=alg.D2_D1) # Decode PQ LMS to LMS - lms = util.pq_st2084_eotf(pqlms) + lms = util.eotf_st2084(pqlms) # Convert back to absolute XYZ D65 - absxyz = alg.matmul(lms_to_xyz_mi, lms, dims=alg.D2_D1) + absxyz = alg.matmul_x3(lms_to_xyz_mi, lms, dims=alg.D2_D1) # Convert back to normal XYZ D65 return util.absxyz_to_xyz(absxyz, YW) @@ -76,13 +76,13 @@ def xyz_d65_to_ictcp(xyzd65: Vector) -> Vector: absxyz = util.xyz_to_absxyz(xyzd65, YW) # Convert to LMS - lms = alg.matmul(xyz_to_lms_m, absxyz, dims=alg.D2_D1) + lms = alg.matmul_x3(xyz_to_lms_m, absxyz, dims=alg.D2_D1) # PQ encode the LMS - pqlms = util.pq_st2084_oetf(lms) + pqlms = util.inverse_eotf_st2084(lms) # Calculate Izazbz - return alg.matmul(lms_p_to_ictcp_m, pqlms, dims=alg.D2_D1) + return alg.matmul_x3(lms_p_to_ictcp_m, pqlms, dims=alg.D2_D1) class ICtCp(Lab): @@ -90,11 +90,11 @@ class ICtCp(Lab): BASE = "xyz-d65" NAME = "ictcp" - SERIALIZE = ("ictcp", "--ictcp",) + SERIALIZE = ("--ictcp", "ictcp") CHANNELS = ( Channel("i", 0.0, 1.0), - Channel("ct", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), - Channel("cp", -1.0, 1.0, flags=FLG_MIRROR_PERCENT) + Channel("ct", -0.5, 0.5, flags=FLG_MIRROR_PERCENT), + Channel("cp", -0.5, 0.5, flags=FLG_MIRROR_PERCENT) ) CHANNEL_ALIASES = { "intensity": "i", @@ -104,6 +104,11 @@ class ICtCp(Lab): WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' + def lightness_name(self) -> str: + """Get lightness name.""" + + return "i" + def to_base(self, coords: Vector) -> Vector: """To XYZ from ICtCp.""" diff --git a/lib/coloraide/spaces/ictcp/css.py b/lib/coloraide/spaces/ictcp/css.py new file mode 100644 index 0000000..218f2ed --- /dev/null +++ b/lib/coloraide/spaces/ictcp/css.py @@ -0,0 +1,51 @@ +"""ICtCp class.""" +from __future__ import annotations +from .. import ictcp as base +from ...css import parse +from ...css import serialize +from ...types import Vector +from typing import Any, Sequence, TYPE_CHECKING + +if TYPE_CHECKING: #pragma: no cover + from ...color import Color + + +class ICtCp(base.ICtCp): + """ICtCp class.""" + + def to_string( + self, + parent: Color, + *, + alpha: bool | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, + fit: bool | str | dict[str, Any] = True, + none: bool = False, + color: bool = False, + percent: bool | Sequence[bool] = False, + **kwargs: Any + ) -> str: + """Convert to CSS.""" + + return serialize.serialize_css( + parent, + func='ictcp', + alpha=alpha, + precision=precision, + rounding=rounding, + fit=fit, + none=none, + color=color, + percent=percent + ) + + def match( + self, + string: str, + start: int = 0, + fullmatch: bool = True + ) -> 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/igpgtg.py b/lib/coloraide/spaces/igpgtg.py index 06268b6..af81dbc 100644 --- a/lib/coloraide/spaces/igpgtg.py +++ b/lib/coloraide/spaces/igpgtg.py @@ -38,25 +38,25 @@ def xyz_to_igpgtg(xyz: Vector) -> Vector: """XYZ to IgPgTg.""" - lms_in = alg.matmul(XYZ_TO_LMS, xyz, dims=alg.D2_D1) + lms_in = alg.matmul_x3(XYZ_TO_LMS, xyz, dims=alg.D2_D1) lms = [ 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.matmul(LMS_TO_IGPGTG, lms, dims=alg.D2_D1) + return alg.matmul_x3(LMS_TO_IGPGTG, lms, dims=alg.D2_D1) def igpgtg_to_xyz(itp: Vector) -> Vector: """IgPgTg to XYZ.""" - lms = alg.matmul(IGPGTG_TO_LMS, itp, dims=alg.D2_D1) + lms = alg.matmul_x3(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.matmul(LMS_TO_XYZ, lms_in, dims=alg.D2_D1) + return alg.matmul_x3(LMS_TO_XYZ, lms_in, dims=alg.D2_D1) class IgPgTg(IPT): @@ -77,6 +77,11 @@ class IgPgTg(IPT): } WHITE = WHITES['2deg']['D65'] + def lightness_name(self) -> str: + """Get lightness name.""" + + return "ig" + def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/ipt.py b/lib/coloraide/spaces/ipt.py index cd210b7..3e12d06 100644 --- a/lib/coloraide/spaces/ipt.py +++ b/lib/coloraide/spaces/ipt.py @@ -8,24 +8,25 @@ from .lab import Lab from ..channels import Channel, FLG_MIRROR_PERCENT from .. import algebra as alg -from ..types import Vector from .. import util +from ..types import Vector +# IPT matrices for LMS conversion with better accuracy for 64 bit doubles XYZ_TO_LMS = [ - [0.4002, 0.7075, -0.0807], - [-0.2280, 1.1500, 0.0612], - [0.0, 0.0, 0.9184] + [0.40021823485770675, 0.7075142362766385, -0.08070681117219487], + [-0.2279857874604858, 1.1499981023974668, 0.061235733313416064], + [0.0, 0.0, 0.918357975939021] ] LMS_TO_XYZ = [ - [1.8502429449432054, -1.1383016378672328, 0.23843495850870136], - [0.3668307751713486, 0.6438845448402355, -0.010673443584379994], - [0.0, 0.0, 1.088850174216028] + [1.8502, -1.1383, 0.2385], + [0.3668, 0.6439, -0.0107], + [0.0, 0.0, 1.0889] ] LMS_P_TO_IPT = [ - [0.4, 0.4, 0.2], - [4.455, -4.851, 0.396], + [0.4000, 0.4000, 0.2000], + [4.4550, -4.8510, 0.3960], [0.8056, 0.3572, -1.1628] ] @@ -39,15 +40,15 @@ def xyz_to_ipt(xyz: Vector) -> Vector: """XYZ to IPT.""" - 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) + lms_p = [alg.spow(c, 0.43) for c in alg.matmul_x3(XYZ_TO_LMS, xyz, dims=alg.D2_D1)] + return alg.matmul_x3(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.matmul(IPT_TO_LMS_P, ipt, dims=alg.D2_D1)] - return alg.matmul(LMS_TO_XYZ, lms, dims=alg.D2_D1) + lms = [alg.nth_root(c, 0.43) for c in alg.matmul_x3(IPT_TO_LMS_P, ipt, dims=alg.D2_D1)] + return alg.matmul_x3(LMS_TO_XYZ, lms, dims=alg.D2_D1) class IPT(Lab): @@ -72,6 +73,11 @@ class IPT(Lab): # 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] + def lightness_name(self) -> str: + """Get lightness name.""" + + return "i" + def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/jzazbz.py b/lib/coloraide/spaces/jzazbz/__init__.py similarity index 68% rename from lib/coloraide/spaces/jzazbz.py rename to lib/coloraide/spaces/jzazbz/__init__.py index 95548a2..4542a3e 100644 --- a/lib/coloraide/spaces/jzazbz.py +++ b/lib/coloraide/spaces/jzazbz/__init__.py @@ -10,13 +10,12 @@ If at some time that these assumptions are incorrect, we will be happy to alter the model. """ 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 ..types import Vector, Matrix # noqa: F401 -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, Matrix # noqa: F401 +from ..lab import Lab B = 1.15 G = 0.66 @@ -66,7 +65,7 @@ ] -def xyz_d65_to_izazbz(xyz: Vector, lms_matrix: Matrix, m2: float) -> Vector: +def xyz_to_izazbz(xyz: Vector, lms_matrix: Matrix, m2: float) -> Vector: """Absolute XYZ to Izazbz.""" xa, ya, za = xyz @@ -74,33 +73,33 @@ def xyz_d65_to_izazbz(xyz: Vector, lms_matrix: Matrix, m2: float) -> Vector: ym = (G * ya) - ((G - 1) * xa) # Convert to LMS - lms = alg.matmul(XYZ_TO_LMS, [xm, ym, za], dims=alg.D2_D1) + lms = alg.matmul_x3(XYZ_TO_LMS, [xm, ym, za], dims=alg.D2_D1) # PQ encode the LMS - pqlms = util.pq_st2084_oetf(lms, m2=m2) + pqlms = util.inverse_eotf_st2084(lms, m2=m2) # Calculate Izazbz - return alg.matmul(lms_matrix, pqlms, dims=alg.D2_D1) + return alg.matmul_x3(lms_matrix, pqlms, dims=alg.D2_D1) -def izazbz_to_xyz_d65(izazbz: Vector, lms_matrix: Matrix, m2: float) -> Vector: +def izazbz_to_xyz(izazbz: Vector, lms_matrix: Matrix, m2: float) -> Vector: """Izazbz to absolute XYZ.""" # Convert to LMS prime - pqlms = alg.matmul(lms_matrix, izazbz, dims=alg.D2_D1) + pqlms = alg.matmul_x3(lms_matrix, izazbz, dims=alg.D2_D1) # Decode PQ LMS to LMS - lms = util.pq_st2084_eotf(pqlms, m2=m2) + lms = util.eotf_st2084(pqlms, m2=m2) # Convert back to absolute XYZ D65 - xm, ym, za = alg.matmul(LMS_TO_XYZ, lms, dims=alg.D2_D1) + xm, ym, za = alg.matmul_x3(LMS_TO_XYZ, lms, dims=alg.D2_D1) xa = (xm + ((B - 1) * za)) / B ya = (ym + ((G - 1) * xa)) / G return [xa, ya, za] -def jzazbz_to_xyz_d65(jzazbz: Vector) -> Vector: +def jzazbz_to_xyz(jzazbz: Vector) -> Vector: """From Jzazbz to XYZ.""" jz, az, bz = jzazbz @@ -109,29 +108,29 @@ def jzazbz_to_xyz_d65(jzazbz: Vector) -> Vector: iz = alg.zdiv((jz + D0), (1 + D - D * (jz + D0))) # Convert back to normal XYZ D65 - return util.absxyz_to_xyz(izazbz_to_xyz_d65([iz, az, bz], IZAZBZ_TO_LMS_P, M2), YW) + return util.absxyz_to_xyz(izazbz_to_xyz([iz, az, bz], IZAZBZ_TO_LMS_P, M2), YW) -def xyz_d65_to_jzazbz(xyzd65: Vector) -> Vector: +def xyz_to_jzazbz(xyz: Vector) -> Vector: """From XYZ to Jzazbz.""" - iz, az, bz = xyz_d65_to_izazbz(util.xyz_to_absxyz(xyzd65, YW), LMS_P_TO_IZAZBZ, M2) + iz, az, bz = xyz_to_izazbz(util.xyz_to_absxyz(xyz, YW), LMS_P_TO_IZAZBZ, M2) # Calculate Jz jz = ((1 + D) * iz) / (1 + (D * iz)) - D0 return [jz, az, bz] -class Jzazbz(Lab, Space): +class Jzazbz(Lab): """Jzazbz class.""" BASE = "xyz-d65" NAME = "jzazbz" - SERIALIZE = ("jzazbz", "--jzazbz",) + SERIALIZE = ("--jzazbz", "jzazbz") CHANNELS = ( 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("az", -0.21, 0.21, flags=FLG_MIRROR_PERCENT), + Channel("bz", -0.21, 0.21, flags=FLG_MIRROR_PERCENT) ) CHANNEL_ALIASES = { "lightness": 'jz', @@ -142,12 +141,17 @@ class Jzazbz(Lab, Space): WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' + def lightness_name(self) -> str: + """Get lightness name.""" + + return "jz" + def to_base(self, coords: Vector) -> Vector: """To XYZ from Jzazbz.""" - return jzazbz_to_xyz_d65(coords) + return jzazbz_to_xyz(coords) def from_base(self, coords: Vector) -> Vector: """From XYZ to Jzazbz.""" - return xyz_d65_to_jzazbz(coords) + return xyz_to_jzazbz(coords) diff --git a/lib/coloraide/spaces/jzazbz/css.py b/lib/coloraide/spaces/jzazbz/css.py new file mode 100644 index 0000000..9ee42c6 --- /dev/null +++ b/lib/coloraide/spaces/jzazbz/css.py @@ -0,0 +1,51 @@ +"""Jzazbz class.""" +from __future__ import annotations +from .. import jzazbz as base +from ...css import parse +from ...css import serialize +from ...types import Vector +from typing import Any, Sequence, TYPE_CHECKING + +if TYPE_CHECKING: #pragma: no cover + from ...color import Color + + +class Jzazbz(base.Jzazbz): + """Jzazbz class.""" + + def to_string( + self, + parent: Color, + *, + alpha: bool | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, + fit: bool | str | dict[str, Any] = True, + none: bool = False, + color: bool = False, + percent: bool | Sequence[bool] = False, + **kwargs: Any + ) -> str: + """Convert to CSS.""" + + return serialize.serialize_css( + parent, + func='jzazbz', + alpha=alpha, + precision=precision, + rounding=rounding, + fit=fit, + none=none, + color=color, + percent=percent + ) + + def match( + self, + string: str, + start: int = 0, + fullmatch: bool = True + ) -> 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/jzczhz.py b/lib/coloraide/spaces/jzczhz/__init__.py similarity index 75% rename from lib/coloraide/spaces/jzczhz.py rename to lib/coloraide/spaces/jzczhz/__init__.py index 7ca71ea..979f1ad 100644 --- a/lib/coloraide/spaces/jzczhz.py +++ b/lib/coloraide/spaces/jzczhz/__init__.py @@ -4,9 +4,9 @@ https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272 """ from __future__ import annotations -from ..cat import WHITES -from .lch import LCh -from ..channels import Channel, FLG_ANGLE +from ...cat import WHITES +from ..lch import LCh +from ...channels import Channel, FLG_ANGLE class JzCzhz(LCh): @@ -18,7 +18,7 @@ class JzCzhz(LCh): BASE = "jzazbz" NAME = "jzczhz" - SERIALIZE = ("jzczhz", "--jzczhz",) + SERIALIZE = ("--jzczhz", "jzczhz") WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' CHANNEL_ALIASES = { @@ -31,10 +31,15 @@ class JzCzhz(LCh): } CHANNELS = ( Channel("jz", 0.0, 1.0), - Channel("cz", 0.0, 1.0), + Channel("cz", 0.0, 0.26), Channel("hz", 0.0, 360.0, flags=FLG_ANGLE) ) + def lightness_name(self) -> str: + """Get lightness name.""" + + return "jz" + def hue_name(self) -> str: """Hue name.""" diff --git a/lib/coloraide/spaces/jzczhz/css.py b/lib/coloraide/spaces/jzczhz/css.py new file mode 100644 index 0000000..ece0a4a --- /dev/null +++ b/lib/coloraide/spaces/jzczhz/css.py @@ -0,0 +1,51 @@ +"""JzCzhz class.""" +from __future__ import annotations +from .. import jzczhz as base +from ...css import parse +from ...css import serialize +from ...types import Vector +from typing import Any, Sequence, TYPE_CHECKING + +if TYPE_CHECKING: #pragma: no cover + from ...color import Color + + +class JzCzhz(base.JzCzhz): + """JzCzhz class.""" + + def to_string( + self, + parent: Color, + *, + alpha: bool | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, + fit: bool | str | dict[str, Any] = True, + none: bool = False, + color: bool = False, + percent: bool | Sequence[bool] = False, + **kwargs: Any + ) -> str: + """Convert to CSS.""" + + return serialize.serialize_css( + parent, + func='jzczhz', + alpha=alpha, + precision=precision, + rounding=rounding, + fit=fit, + none=none, + color=color, + percent=percent + ) + + def match( + self, + string: str, + start: int = 0, + fullmatch: bool = True + ) -> 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/__init__.py b/lib/coloraide/spaces/lab/__init__.py index fa9e753..9e1a27d 100644 --- a/lib/coloraide/spaces/lab/__init__.py +++ b/lib/coloraide/spaces/lab/__init__.py @@ -11,8 +11,8 @@ from ... import util from ... import algebra as alg from ...types import VectorLike, Vector +from typing import Any -ACHROMATIC_THRESHOLD = 1e-4 EPSILON = 216 / 24389 # `6^3 / 29^3` EPSILON3 = 6 / 29 # Cube root of EPSILON KAPPA = 24389 / 27 @@ -37,16 +37,16 @@ def lab_to_xyz(lab: Vector, white: VectorLike) -> Vector: ] # Compute XYZ by scaling `xyz` by reference `white` - return alg.multiply(xyz, white, dims=alg.D1) + return alg.multiply_x3(xyz, white, dims=alg.D1) def xyz_to_lab(xyz: Vector, white: VectorLike) -> Vector: """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) + xyz = alg.divide_x3(xyz, white, dims=alg.D1) # Compute `fx`, `fy`, and `fz` - fx, fy, fz = [alg.nth_root(i, 3) if i > EPSILON else (KAPPA * i + 16) / 116 for i in xyz] + fx, fy, fz = (alg.nth_root(i, 3) if i > EPSILON else (KAPPA * i + 16) / 116 for i in xyz) return [ (116.0 * fy) - 16.0, @@ -67,10 +67,17 @@ class Lab(Labish, Space): "lightness": "l" } + def __init__(self, **kwargs: Any): + """Initialize.""" + + super().__init__(**kwargs) + order = alg.order(round(self.channels[self.indexes()[0]].high, 5)) + self.achromatic_threshold = (1 * 10.0 ** order) / 1_000_000 + def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" - return alg.rect_to_polar(coords[1], coords[2])[0] < ACHROMATIC_THRESHOLD + return alg.rect_to_polar(coords[1], coords[2])[0] < self.achromatic_threshold def to_base(self, coords: Vector) -> Vector: """To XYZ D50 from Lab.""" diff --git a/lib/coloraide/spaces/lab/css.py b/lib/coloraide/spaces/lab/css.py index c36475a..0a02881 100644 --- a/lib/coloraide/spaces/lab/css.py +++ b/lib/coloraide/spaces/lab/css.py @@ -4,9 +4,9 @@ from ...css import parse from ...css import serialize from ...types import Vector -from typing import Any, TYPE_CHECKING, Sequence +from typing import Any, Sequence, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ...color import Color @@ -18,7 +18,8 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -32,6 +33,7 @@ def to_string( func='lab', alpha=alpha, precision=precision, + rounding=rounding, fit=fit, none=none, color=color, diff --git a/lib/coloraide/spaces/lch/__init__.py b/lib/coloraide/spaces/lch/__init__.py index 68c46f1..8ff6c9d 100644 --- a/lib/coloraide/spaces/lch/__init__.py +++ b/lib/coloraide/spaces/lch/__init__.py @@ -1,13 +1,13 @@ """LCh class.""" from __future__ import annotations +from ... import algebra as alg from ...spaces import Space, LChish from ...cat import WHITES from ...channels import Channel, FLG_ANGLE from ... import util import math from ...types import Vector - -ACHROMATIC_THRESHOLD = 1e-4 +from typing import Any def lab_to_lch(lab: Vector) -> Vector: @@ -47,6 +47,13 @@ class LCh(LChish, Space): "hue": "h" } + def __init__(self, **kwargs: Any): + """Initialize.""" + + super().__init__(**kwargs) + order = alg.order(round(self.channels[self.indexes()[0]].high, 5)) + self.achromatic_threshold = (1 * 10.0 ** order) / 1_000_000 + def normalize(self, coords: Vector) -> Vector: """Normalize coordinates.""" @@ -60,7 +67,7 @@ def is_achromatic(self, coords: Vector) -> bool | None: """Check if color is achromatic.""" # Account for both positive and negative chroma - return abs(coords[1]) < ACHROMATIC_THRESHOLD + return abs(coords[1]) < self.achromatic_threshold def to_base(self, coords: Vector) -> Vector: """To Lab from LCh.""" diff --git a/lib/coloraide/spaces/lch/css.py b/lib/coloraide/spaces/lch/css.py index 89b2b1e..4b8f184 100644 --- a/lib/coloraide/spaces/lch/css.py +++ b/lib/coloraide/spaces/lch/css.py @@ -4,9 +4,9 @@ from ...css import parse from ...css import serialize from ...types import Vector -from typing import Any, TYPE_CHECKING, Sequence +from typing import Any, Sequence, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ...color import Color @@ -18,7 +18,8 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -32,6 +33,7 @@ def to_string( func='lch', alpha=alpha, precision=precision, + rounding=rounding, fit=fit, none=none, color=color, diff --git a/lib/coloraide/spaces/lchuv.py b/lib/coloraide/spaces/lchuv.py index 2cf4737..ce3a8ab 100644 --- a/lib/coloraide/spaces/lchuv.py +++ b/lib/coloraide/spaces/lchuv.py @@ -1,13 +1,12 @@ """LChuv class.""" from __future__ import annotations -from ..spaces import Space +from .lch import LCh from ..cat import WHITES from ..channels import Channel, FLG_ANGLE -from .lch import LCh, ACHROMATIC_THRESHOLD from ..types import Vector -class LChuv(LCh, Space): +class LChuv(LCh): """LChuv class.""" BASE = "luv" @@ -23,4 +22,4 @@ class LChuv(LCh, Space): def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" - return coords[0] == 0.0 or coords[1] < ACHROMATIC_THRESHOLD + return coords[0] == 0.0 or abs(coords[1]) < self.achromatic_threshold diff --git a/lib/coloraide/spaces/luv.py b/lib/coloraide/spaces/luv.py index b96404b..ac6aefe 100644 --- a/lib/coloraide/spaces/luv.py +++ b/lib/coloraide/spaces/luv.py @@ -4,10 +4,9 @@ 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 -from .lab import KAPPA, EPSILON, KE, ACHROMATIC_THRESHOLD +from .lab import KAPPA, EPSILON, KE, Lab from .. import util from .. import algebra as alg from ..types import Vector @@ -23,10 +22,11 @@ def xyz_to_luv(xyz: Vector, white: tuple[float, float]) -> Vector: yr = xyz[1] / w_xyz[1] l = 116 * alg.nth_root(yr, 3) - 16 if yr > EPSILON else KAPPA * yr + n = 13 * l return [ l, - 13 * l * (u - ur), - 13 * l * (v - vr), + n * (u - ur), + n * (v - vr), ] @@ -37,24 +37,27 @@ def luv_to_xyz(luv: Vector, white: tuple[float, float]) -> Vector: w_xyz = util.xy_to_xyz(white) ur, vr = util.xy_to_uv(white) - if l != 0: - up = (u / (13 * l)) + ur - vp = (v / (13 * l)) + vr + + if l: + d = 13 * l + up = (u / d) + ur + vp = (v / d) + vr else: - up = vp = 0 + up = vp = 0.0 y = w_xyz[1] * (((l + 16) / 116) ** 3 if l > KE else l / KAPPA) - if vp != 0: - x = y * ((9 * up) / (4 * vp)) - z = y * ((12 - 3 * up - 20 * vp) / (4 * vp)) + if vp: + d = 4 * vp + x = y * (9 * up) / d + z = y * (12 - 3 * up - 20 * vp) / d else: - x = z = 0 + x = z = 0.0 return [x, y, z] -class Luv(Labish, Space): +class Luv(Lab): """Luv class.""" BASE = "xyz-d65" @@ -73,7 +76,7 @@ class Luv(Labish, Space): def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" - return coords[0] == 0.0 or alg.rect_to_polar(coords[1], coords[2])[0] < ACHROMATIC_THRESHOLD + return coords[0] == 0.0 or alg.rect_to_polar(coords[1], coords[2])[0] < self.achromatic_threshold def to_base(self, coords: Vector) -> Vector: """To XYZ D50 from Luv.""" diff --git a/lib/coloraide/spaces/okhsl.py b/lib/coloraide/spaces/okhsl.py index b2de597..397c123 100644 --- a/lib/coloraide/spaces/okhsl.py +++ b/lib/coloraide/spaces/okhsl.py @@ -78,16 +78,16 @@ K_3 = (1.0 + K_1) / (1.0 + K_2) -def toe(x: float) -> float: +def toe(x: float, k1: float = K_1, k2: float = K_2, k3: float = K_3) -> float: """Toe function for L_r.""" - return 0.5 * (K_3 * x - K_1 + math.sqrt((K_3 * x - K_1) * (K_3 * x - K_1) + 4 * K_2 * K_3 * x)) + return 0.5 * (k3 * x - k1 + math.sqrt((k3 * x - k1) * (k3 * x - k1) + 4 * k2 * k3 * x)) -def toe_inv(x: float) -> float: +def toe_inv(x: float, k1: float = K_1, k2: float = K_2, k3: float = K_3) -> float: """Inverse toe function for L_r.""" - return (x ** 2 + K_1 * x) / (K_3 * (x + K_2)) + return (x ** 2 + k1 * x) / (k3 * (x + k2)) def to_st(cusp: Vector) -> Vector: @@ -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.matmul( + return alg.matmul_x3( lms_to_rgb, - [c ** 3 for c in alg.matmul(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], + [c ** 3 for c in alg.matmul_x3(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -240,23 +240,23 @@ def find_gamut_intersection( mdt2 = 6 * (m_dt ** 2) * m_ sdt2 = 6 * (s_dt ** 2) * s_ - r = alg.vdot(lms_to_rgb[0], [l, m, s]) - 1 - r1 = alg.vdot(lms_to_rgb[0], [ldt, mdt, sdt]) - r2 = alg.vdot(lms_to_rgb[0], [ldt2, mdt2, sdt2]) + r = alg.matmul_x3(lms_to_rgb[0], [l, m, s], dims=alg.D1) - 1 + r1 = alg.matmul_x3(lms_to_rgb[0], [ldt, mdt, sdt], dims=alg.D1) + r2 = alg.matmul_x3(lms_to_rgb[0], [ldt2, mdt2, sdt2], dims=alg.D1) u_r = r1 / (r1 * r1 - 0.5 * r * r2) t_r = -r * u_r - g = alg.vdot(lms_to_rgb[1], [l, m, s]) - 1 - g1 = alg.vdot(lms_to_rgb[1], [ldt, mdt, sdt]) - g2 = alg.vdot(lms_to_rgb[1], [ldt2, mdt2, sdt2]) + g = alg.matmul_x3(lms_to_rgb[1], [l, m, s], dims=alg.D1) - 1 + g1 = alg.matmul_x3(lms_to_rgb[1], [ldt, mdt, sdt], dims=alg.D1) + g2 = alg.matmul_x3(lms_to_rgb[1], [ldt2, mdt2, sdt2], dims=alg.D1) u_g = g1 / (g1 * g1 - 0.5 * g * g2) t_g = -g * u_g - b = alg.vdot(lms_to_rgb[2], [l, m, s]) - 1 - b1 = alg.vdot(lms_to_rgb[2], [ldt, mdt, sdt]) - b2 = alg.vdot(lms_to_rgb[2], [ldt2, mdt2, sdt2]) + b = alg.matmul_x3(lms_to_rgb[2], [l, m, s], dims=alg.D1) - 1 + b1 = alg.matmul_x3(lms_to_rgb[2], [ldt, mdt, sdt], dims=alg.D1) + b2 = alg.matmul_x3(lms_to_rgb[2], [ldt2, mdt2, sdt2], dims=alg.D1) u_b = b1 / (b1 * b1 - 0.5 * b * b2) t_b = -b * u_b diff --git a/lib/coloraide/spaces/okhsv.py b/lib/coloraide/spaces/okhsv.py index f51a3c9..aad9333 100644 --- a/lib/coloraide/spaces/okhsv.py +++ b/lib/coloraide/spaces/okhsv.py @@ -156,12 +156,12 @@ class Okhsv(HSV): GAMUT_CHECK = None CLIP_SPACE = None - def to_base(self, okhsv: Vector) -> Vector: + def to_base(self, coords: Vector) -> Vector: """To Oklab from Okhsv.""" - return okhsv_to_oklab(okhsv, LMS_TO_SRGBL, SRGBL_COEFF) + return okhsv_to_oklab(coords, LMS_TO_SRGBL, SRGBL_COEFF) - def from_base(self, oklab: Vector) -> Vector: + def from_base(self, coords: Vector) -> Vector: """From Oklab to Okhsv.""" - return oklab_to_okhsv(oklab, LMS_TO_SRGBL, SRGBL_COEFF) + return oklab_to_okhsv(coords, LMS_TO_SRGBL, SRGBL_COEFF) diff --git a/lib/coloraide/spaces/oklab/__init__.py b/lib/coloraide/spaces/oklab/__init__.py index 296a43b..1baf9a1 100644 --- a/lib/coloraide/spaces/oklab/__init__.py +++ b/lib/coloraide/spaces/oklab/__init__.py @@ -64,9 +64,9 @@ def oklab_to_xyz_d65(lab: Vector) -> Vector: """Convert from Oklab to XYZ D65.""" - return alg.matmul( + return alg.matmul_x3( LMS_TO_XYZD65, - [c ** 3 for c in alg.matmul(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], + [c ** 3 for c in alg.matmul_x3(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -74,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.matmul( + return alg.matmul_x3( LMS3_TO_OKLAB, - [alg.nth_root(c, 3) for c in alg.matmul(XYZD65_TO_LMS, xyz, dims=alg.D2_D1)], + [alg.nth_root(c, 3) for c in alg.matmul_x3(XYZD65_TO_LMS, xyz, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -97,12 +97,12 @@ class Oklab(Lab): } WHITE = WHITES['2deg']['D65'] - def to_base(self, oklab: Vector) -> Vector: + def to_base(self, coords: Vector) -> Vector: """To XYZ.""" - return oklab_to_xyz_d65(oklab) + return oklab_to_xyz_d65(coords) - def from_base(self, xyz: Vector) -> Vector: + def from_base(self, coords: Vector) -> Vector: """From XYZ.""" - return xyz_d65_to_oklab(xyz) + return xyz_d65_to_oklab(coords) diff --git a/lib/coloraide/spaces/oklab/css.py b/lib/coloraide/spaces/oklab/css.py index 321621c..ffd9bde 100644 --- a/lib/coloraide/spaces/oklab/css.py +++ b/lib/coloraide/spaces/oklab/css.py @@ -4,9 +4,9 @@ from ...css import parse from ...css import serialize from ...types import Vector -from typing import Any, TYPE_CHECKING, Sequence +from typing import Any, Sequence, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ...color import Color @@ -18,7 +18,8 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -32,6 +33,7 @@ def to_string( func='oklab', alpha=alpha, precision=precision, + rounding=rounding, fit=fit, none=none, color=color, diff --git a/lib/coloraide/spaces/oklch/css.py b/lib/coloraide/spaces/oklch/css.py index e81057c..1d79ec0 100644 --- a/lib/coloraide/spaces/oklch/css.py +++ b/lib/coloraide/spaces/oklch/css.py @@ -4,9 +4,9 @@ from ...css import parse from ...css import serialize from ...types import Vector -from typing import Any, TYPE_CHECKING, Sequence +from typing import Any, Sequence, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ...color import Color @@ -18,7 +18,8 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -32,6 +33,7 @@ def to_string( func='oklch', alpha=alpha, precision=precision, + rounding=rounding, fit=fit, none=none, color=color, diff --git a/lib/coloraide/spaces/oklrab.py b/lib/coloraide/spaces/oklrab.py new file mode 100644 index 0000000..5c05eaf --- /dev/null +++ b/lib/coloraide/spaces/oklrab.py @@ -0,0 +1,33 @@ +""" +Oklrab. + +Applies a toe function to Oklab lightness. + +> This new lightness estimate closely matches the lightness estimate of CIELab overall and is nearly equal at 50% +> lightness (Y for CIELab L is 0.18406, and Lr 0.18419) which is useful for compatibility. Worth noting is that it is +> not possible to have a lightness scale that is perfectly uniform independent of viewing conditions and background +> color. This new lightness function is however a better trade-off for cases with a well defined reference white. + +https://bottosson.github.io/posts/colorpicker/#intermission---a-new-lightness-estimate-for-oklab +""" +from . oklab import Oklab +from . okhsl import toe, toe_inv +from .. types import Vector + + +class Oklrab(Oklab): + """Oklrab.""" + + BASE = "oklab" + NAME = "oklrab" + SERIALIZE = ("--oklrab",) + + def to_base(self, coords: Vector) -> Vector: + """To XYZ.""" + + return [toe_inv(coords[0]), coords[1], coords[2]] + + def from_base(self, coords: Vector) -> Vector: + """From XYZ.""" + + return [toe(coords[0]), coords[1], coords[2]] diff --git a/lib/coloraide/spaces/oklrch.py b/lib/coloraide/spaces/oklrch.py new file mode 100644 index 0000000..13494b7 --- /dev/null +++ b/lib/coloraide/spaces/oklrch.py @@ -0,0 +1,21 @@ +""" +OkLrCh. + +Applies a toe function to the OkLCh lightness. + +> This new lightness estimate closely matches the lightness estimate of CIELab overall and is nearly equal at 50% +> lightness (Y for CIELab L is 0.18406, and Lr 0.18419) which is useful for compatibility. Worth noting is that it is +> not possible to have a lightness scale that is perfectly uniform independent of viewing conditions and background +> color. This new lightness function is however a better trade-off for cases with a well defined reference white. + +https://bottosson.github.io/posts/colorpicker/#intermission---a-new-lightness-estimate-for-oklab +""" +from . oklch import OkLCh + + +class OkLrCh(OkLCh): + """OkLrCh.""" + + BASE = "oklrab" + NAME = "oklrch" + SERIALIZE = ("--oklrch",) diff --git a/lib/coloraide/spaces/orgb.py b/lib/coloraide/spaces/orgb.py index 313f1a4..fdcfa98 100644 --- a/lib/coloraide/spaces/orgb.py +++ b/lib/coloraide/spaces/orgb.py @@ -6,7 +6,7 @@ from __future__ import annotations import math from .. import algebra as alg -from ..spaces import Space, Labish +from ..spaces.lab import Lab from ..types import Vector from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT @@ -17,7 +17,11 @@ [0.8660, -0.8660, 0.0000] ] -LC1C2_TO_RGB = alg.inv(RGB_TO_LC1C2) +LC1C2_TO_RGB = [ + [1.0000000000000002, 0.11399999999999999, 0.7436489607390301], + [1.0000000000000002, 0.11399999999999999, -0.4110854503464203], + [1.0000000000000002, -0.886, 0.1662817551963048] +] def rotate(v: Vector, d: float) -> Vector: @@ -26,13 +30,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.matmul(m, v, dims=alg.D2_D1) + return alg.matmul_x3(m, v, dims=alg.D2_D1) def srgb_to_orgb(rgb: Vector) -> Vector: """Convert sRGB to oRGB.""" - lcc = alg.matmul(RGB_TO_LC1C2, rgb, dims=alg.D2_D1) + lcc = alg.matmul_x3(RGB_TO_LC1C2, rgb, dims=alg.D2_D1) theta = math.atan2(lcc[2], lcc[1]) theta0 = theta atheta = abs(theta) @@ -55,17 +59,16 @@ 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.matmul(LC1C2_TO_RGB, rotate(lcc, theta - theta0), dims=alg.D2_D1) + return alg.matmul_x3(LC1C2_TO_RGB, rotate(lcc, theta - theta0), dims=alg.D2_D1) -class oRGB(Labish, Space): +class oRGB(Lab): """oRGB color class.""" BASE = 'srgb' NAME = "orgb" SERIALIZE = ("--orgb",) WHITE = WHITES['2deg']['D65'] - EXTENDED_RANGE = True CHANNELS = ( Channel("l", 0.0, 1.0, bound=True), Channel("cyb", -1.0, 1.0, bound=True, flags=FLG_MIRROR_PERCENT), diff --git a/lib/coloraide/spaces/prismatic.py b/lib/coloraide/spaces/prismatic.py index 4e85eda..8b05047 100644 --- a/lib/coloraide/spaces/prismatic.py +++ b/lib/coloraide/spaces/prismatic.py @@ -7,7 +7,8 @@ https://studylib.net/doc/14656976/the-prismatic-color-space-for-rgb-computations """ from __future__ import annotations -from ..spaces import Space +from .. import util +from ..spaces import Space, Luminant from ..channels import Channel from ..cat import WHITES from ..types import Vector @@ -32,13 +33,12 @@ def lrgb_to_srgb(lrgb: Vector) -> Vector: return [(l * c) / mx for c in rgb] if mx != 0 else [0, 0, 0] -class Prismatic(Space): +class Prismatic(Luminant, Space): """The Prismatic color class.""" BASE = "srgb" NAME = "prismatic" SERIALIZE = ("--prismatic",) # type: tuple[str, ...] - EXTENDED_RANGE = False CHANNELS = ( Channel("l", 0.0, 1.0, bound=True), Channel("r", 0.0, 1.0, bound=True), @@ -58,12 +58,12 @@ class Prismatic(Space): def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" - if math.isclose(0.0, coords[0], abs_tol=1e-4): + if math.isclose(0.0, coords[0], abs_tol=util.ACHROMATIC_THRESHOLD_SM): return True white = [1, 1, 1] for x in alg.vcross(coords[:-1], white): - if not math.isclose(0.0, x, abs_tol=1e-5): + if not math.isclose(0.0, x, abs_tol=util.ACHROMATIC_THRESHOLD_SM): return False return True diff --git a/lib/coloraide/spaces/prophoto_rgb_linear.py b/lib/coloraide/spaces/prophoto_rgb_linear.py index 520777e..ea2abb7 100644 --- a/lib/coloraide/spaces/prophoto_rgb_linear.py +++ b/lib/coloraide/spaces/prophoto_rgb_linear.py @@ -26,13 +26,13 @@ def lin_prophoto_to_xyz(rgb: Vector) -> Vector: http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html """ - return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul_x3(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.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul_x3(XYZ_TO_RGB, xyz, dims=alg.D2_D1) class ProPhotoRGBLinear(sRGBLinear): diff --git a/lib/coloraide/spaces/rec2020.py b/lib/coloraide/spaces/rec2020.py index 4e5cfb2..1e5d07f 100644 --- a/lib/coloraide/spaces/rec2020.py +++ b/lib/coloraide/spaces/rec2020.py @@ -1,50 +1,56 @@ -"""Rec 2020 color class.""" +""" +Rec. 2020 color space (display referred). + +Uses the display referred EOTF as specified in BT.1886. + +- https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.2020-2-201510-I!!PDF-E.pdf +- https://www.itu.int/dms_pubrec/itu-r/rec/bt/r-rec-bt.1886-0-201103-i!!pdf-e.pdf +""" from __future__ import annotations from .srgb_linear import sRGBLinear -import math from .. import algebra as alg from ..types import Vector -ALPHA = 1.09929682680944 -BETA = 0.018053968510807 -BETA45 = BETA * 4.5 -ALPHAM1 = ALPHA - 1 +GAMMA = 2.40 +IGAMMA = 1 / GAMMA -def lin_2020(rgb: Vector) -> Vector: +def inverse_eotf_bt1886(rgb: Vector) -> Vector: """ - Convert an array of rec-2020 RGB values in the range 0.0 - 1.0 to linear light (un-corrected) form. + Inverse ITU-R BT.1886 EOTF. + + ``` + igamma = 1 / gamma - https://en.wikipedia.org/wiki/Rec._2020#Transfer_characteristics + d = lw ** igamma - lb ** igamma + a = d ** gamma + b = lb ** igamma / d + return [math.copysign(a * alg.spow(abs(l) / a, igamma) - b, l) for l in rgb] + ``` + + When using `lb == 0`, `lw == 1`, and gamma of `2.4`, this simplifies to a simple power of `1 / 2.4`. """ - result = [] - for i in rgb: - # Mirror linear nature of algorithm on the negative axis - abs_i = abs(i) - if abs_i < BETA45: - result.append(i / 4.5) - else: - result.append(math.copysign(alg.nth_root((abs_i + ALPHAM1) / ALPHA, 0.45), i)) - return result + return [alg.spow(v, IGAMMA) for v in rgb] -def gam_2020(rgb: Vector) -> Vector: +def eotf_bt1886(rgb: Vector) -> Vector: """ - Convert an array of linear-light rec-2020 RGB in the range 0.0-1.0 to gamma corrected form. + ITU-R BT.1886 EOTF. + + ``` + igamma = 1 / gamma + + d = lw ** igamma - lb ** igamma + a = d ** gamma + b = lb ** igamma / d + return [math.copysign(a * alg.spow(max(abs(v) + b, 0), gamma), v) for v in rgb] + ``` - https://en.wikipedia.org/wiki/Rec._2020#Transfer_characteristics + When using `lb == 0`, `lw == 1`, and gamma of `2.4`, this simplifies to a simple power of `2.4`. """ - result = [] - for i in rgb: - # Mirror linear nature of algorithm on the negative axis - abs_i = abs(i) - if abs_i < BETA: - result.append(4.5 * i) - else: - result.append(math.copysign(ALPHA * (abs_i ** 0.45) - ALPHAM1, i)) - return result + return [alg.spow(v, GAMMA) for v in rgb] class Rec2020(sRGBLinear): @@ -61,9 +67,9 @@ def linear(self) -> str: def to_base(self, coords: Vector) -> Vector: """To XYZ from Rec. 2020.""" - return lin_2020(coords) + return eotf_bt1886(coords) def from_base(self, coords: Vector) -> Vector: """From XYZ to Rec. 2020.""" - return gam_2020(coords) + return inverse_eotf_bt1886(coords) diff --git a/lib/coloraide/spaces/rec2020_linear.py b/lib/coloraide/spaces/rec2020_linear.py index 950346e..344262b 100644 --- a/lib/coloraide/spaces/rec2020_linear.py +++ b/lib/coloraide/spaces/rec2020_linear.py @@ -30,13 +30,13 @@ def lin_2020_to_xyz(rgb: Vector) -> Vector: http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html """ - return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul_x3(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.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul_x3(XYZ_TO_RGB, xyz, dims=alg.D2_D1) class Rec2020Linear(sRGBLinear): diff --git a/lib/coloraide/spaces/rec2020_oetf.py b/lib/coloraide/spaces/rec2020_oetf.py new file mode 100644 index 0000000..50d33d8 --- /dev/null +++ b/lib/coloraide/spaces/rec2020_oetf.py @@ -0,0 +1,61 @@ +""" +Rec. 2020 color space (scene referred). + +Uses the default OETF specified in the ITU-R BT2020 spec. +https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.2020-2-201510-I!!PDF-E.pdf +""" +from __future__ import annotations +import math +from .rec2020 import Rec2020 +from .. import algebra as alg +from ..types import Vector + +ALPHA = 1.09929682680944 +BETA = 0.018053968510807 +BETA45 = BETA * 4.5 +ALPHAM1 = ALPHA - 1 + + +def inverse_oetf_bt2020(rgb: Vector) -> Vector: + """Convert an array of rec-2020 RGB values in the range 0.0 - 1.0 to linear light (un-corrected) form.""" + + result = [] + for i in rgb: + # Mirror linear nature of algorithm on the negative axis + abs_i = abs(i) + if abs_i < BETA45: + result.append(i / 4.5) + else: + result.append(math.copysign(alg.nth_root((abs_i + ALPHAM1) / ALPHA, 0.45), i)) + return result + + +def oetf_bt2020(rgb: Vector) -> Vector: + """Convert an array of linear-light rec-2020 RGB in the range 0.0-1.0 to gamma corrected form.""" + + result = [] + for i in rgb: + # Mirror linear nature of algorithm on the negative axis + abs_i = abs(i) + if abs_i < BETA: + result.append(4.5 * i) + else: + result.append(math.copysign(ALPHA * (abs_i ** 0.45) - ALPHAM1, i)) + return result + + +class Rec2020OETF(Rec2020): + """Rec 2020 class using OETF gamma correction.""" + + NAME = "rec2020-oetf" + SERIALIZE = ("--rec2020-oetf",) + + def to_base(self, coords: Vector) -> Vector: + """To XYZ from Rec. 2020.""" + + return inverse_oetf_bt2020(coords) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ to Rec. 2020.""" + + return oetf_bt2020(coords) diff --git a/lib/coloraide/spaces/rec2100_hlg.py b/lib/coloraide/spaces/rec2100_hlg.py index 854b1e7..6db1ecd 100644 --- a/lib/coloraide/spaces/rec2100_hlg.py +++ b/lib/coloraide/spaces/rec2100_hlg.py @@ -65,23 +65,23 @@ def hlg_black_level_lift(lw: float = 0.0, lb: float = 1000.0) -> float: return math.sqrt(3 * (lb / lw) ** (1 / hlg_gamma(lw))) -def hlg_oetf(values: Vector, env: Environment) -> Vector: +def oetf_hlg(values: Vector, env: Environment) -> Vector: """HLG OETF.""" adjusted = [] # type: Vector - for e in values: - e = alg.nth_root(3 * e, 2) if e <= 1 / 12 else env.a * math.log(12 * e - env.b) + env.c - adjusted.append((e - env.beta) / (1 - env.beta)) + for v in values: + v = alg.nth_root(3 * v, 2) if v <= 1 / 12 else env.a * math.log(12 * v - env.b) + env.c + adjusted.append((v - env.beta) / (1 - env.beta)) return adjusted -def hlg_eotf(values: Vector, env: Environment) -> Vector: - """HLG EOTF.""" +def inverse_oetf_hlg(values: Vector, env: Environment) -> Vector: + """HLG inverse OETF.""" adjusted = [] # type: Vector - for e in values: - e = (1 - env.beta) * e + env.beta - adjusted.append((e ** 2) / 3 if e <= 0.5 else (math.exp((e - env.c) / env.a) + env.b) / 12) + for v in values: + v = (1 - env.beta) * v + env.beta + adjusted.append((v ** 2 / 3) if v <= 0.5 else (math.exp((v - env.c) / env.a) + env.b) / 12) return adjusted @@ -107,9 +107,9 @@ def linear(self) -> str: def to_base(self, coords: Vector) -> Vector: """To base from Rec 2100 HLG.""" - return [c * self.ENV.inv_scale for c in hlg_eotf(coords, self.ENV)] + return [c * self.ENV.inv_scale for c in inverse_oetf_hlg(coords, self.ENV)] def from_base(self, coords: Vector) -> Vector: """From base to Rec. 2100 HLG.""" - return hlg_oetf([c * self.ENV.scale for c in coords], self.ENV) + return oetf_hlg([c * self.ENV.scale for c in coords], self.ENV) diff --git a/lib/coloraide/spaces/rec2100_pq.py b/lib/coloraide/spaces/rec2100_pq.py index edef8d4..650d49f 100644 --- a/lib/coloraide/spaces/rec2100_pq.py +++ b/lib/coloraide/spaces/rec2100_pq.py @@ -29,9 +29,9 @@ def linear(self) -> str: def to_base(self, coords: Vector) -> Vector: """To base from Rec. 2100 PQ.""" - return [c / YW for c in util.pq_st2084_eotf(coords)] + return [c / YW for c in util.eotf_st2084(coords)] def from_base(self, coords: Vector) -> Vector: """From base to Rec. 2100 PQ.""" - return util.pq_st2084_oetf([c * YW for c in coords]) + return util.inverse_eotf_st2084([c * YW for c in coords]) diff --git a/lib/coloraide/spaces/rec709.py b/lib/coloraide/spaces/rec709.py index 1eef141..eb26eab 100644 --- a/lib/coloraide/spaces/rec709.py +++ b/lib/coloraide/spaces/rec709.py @@ -1,62 +1,23 @@ """ -Rec. 709 color space class. +Rec. 709 color space class (display-referred). + +Uses the display referred EOTF as specified in BT.1886. This color space uses the same chromaticities and white points as sRGB, -but uses the same gamma correction as Rec. 2020, just at 10 bit precision. +but uses the same gamma correction as Rec. 2020. - 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 +- https://www.itu.int/dms_pubrec/itu-r/rec/bt/r-rec-bt.1886-0-201103-i!!pdf-e.pdf """ from __future__ import annotations from .srgb_linear import sRGBLinear -import math -from .. import algebra as alg +from .rec2020 import inverse_eotf_bt1886, eotf_bt1886 from ..types import Vector -ALPHA = 1.099 -BETA = 0.018 -BETA45 = BETA * 4.5 -ALPHAM1 = 0.099 - - -def lin_709(rgb: Vector) -> Vector: - """ - Convert an array of Rec. 709 RGB values in the range 0.0 - 1.0 to linear light (un-corrected) form. - - Transfer function is similar to Rec. 2020, just at a lower precision - """ - - result = [] - for i in rgb: - # Mirror linear nature of algorithm on the negative axis - abs_i = abs(i) - if abs_i < BETA45: - result.append(i / 4.5) - else: - result.append(math.copysign(alg.nth_root((abs_i + ALPHAM1) / ALPHA, 0.45), i)) - return result - - -def gam_709(rgb: Vector) -> Vector: - """ - Convert an array of linear-light Rec. 709 RGB in the range 0.0-1.0 to gamma corrected form. - - Transfer function is similar to Rec. 2020, just at a lower precision - """ - - result = [] - for i in rgb: - # Mirror linear nature of algorithm on the negative axis - abs_i = abs(i) - if abs_i < BETA: - result.append(4.5 * i) - else: - result.append(math.copysign(ALPHA * (abs_i ** 0.45) - ALPHAM1, i)) - return result - class Rec709(sRGBLinear): - """Rec. 709 class.""" + """Rec. 709 class Using the display-referred EOTF as specified in BT.1886.""" BASE = "srgb-linear" NAME = "rec709" @@ -70,9 +31,9 @@ def linear(self) -> str: def to_base(self, coords: Vector) -> Vector: """To XYZ from Rec. 709.""" - return lin_709(coords) + return eotf_bt1886(coords) def from_base(self, coords: Vector) -> Vector: """From XYZ to Rec. 709.""" - return gam_709(coords) + return inverse_eotf_bt1886(coords) diff --git a/lib/coloraide/spaces/rec709_oetf.py b/lib/coloraide/spaces/rec709_oetf.py new file mode 100644 index 0000000..5aca8ea --- /dev/null +++ b/lib/coloraide/spaces/rec709_oetf.py @@ -0,0 +1,73 @@ +""" +Rec. 709 color space class (scene-referred). + +This color space uses the same chromaticities and white points as sRGB, +but uses the same gamma correction as Rec. 2020, just at 10 bit precision. + +Transfer function of BT.709 matches BT.601. + +- 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 +- https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.601-7-201103-I!!PDF-E.pdf +""" +from __future__ import annotations +from .srgb_linear import sRGBLinear +import math +from .. import algebra as alg +from ..types import Vector + +ALPHA = 1.099 +BETA = 0.018 +BETA45 = BETA * 4.5 +ALPHAM1 = 0.099 + + +def inverse_oetf_bt709(rgb: Vector) -> Vector: + """Convert an array of Rec. 709 RGB values in the range 0.0 - 1.0 to linear light (un-corrected) form.""" + + result = [] + for i in rgb: + # Mirror linear nature of algorithm on the negative axis + abs_i = abs(i) + if abs_i < BETA45: + result.append(i / 4.5) + else: + result.append(math.copysign(alg.nth_root((abs_i + ALPHAM1) / ALPHA, 0.45), i)) + return result + + +def oetf_bt709(rgb: Vector) -> Vector: + """Convert an array of linear-light Rec. 709 RGB in the range 0.0-1.0 to gamma corrected form.""" + + result = [] + for i in rgb: + # Mirror linear nature of algorithm on the negative axis + abs_i = abs(i) + if abs_i < BETA: + result.append(4.5 * i) + else: + result.append(math.copysign(ALPHA * (abs_i ** 0.45) - ALPHAM1, i)) + return result + + +class Rec709OETF(sRGBLinear): + """Rec. 709 class using the OETF in the BT. 709 specification.""" + + BASE = "srgb-linear" + NAME = "rec709-oetf" + SERIALIZE = ("--rec709-oetf",) + + 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.""" + + return inverse_oetf_bt709(coords) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ to Rec. 709.""" + + return oetf_bt709(coords) diff --git a/lib/coloraide/spaces/rlab.py b/lib/coloraide/spaces/rlab.py index aa57d51..ff0dcf5 100644 --- a/lib/coloraide/spaces/rlab.py +++ b/lib/coloraide/spaces/rlab.py @@ -71,7 +71,7 @@ def __init__( def calc_ram(self) -> Matrix: """Calculate RAM.""" - lms = alg.matmul(M, self.ref_white, dims=alg.D2_D1) + lms = alg.matmul_x3(M, self.ref_white, dims=alg.D2_D1) a = [] # type: Vector s = sum(lms) for c in lms: @@ -89,14 +89,14 @@ def rlab_to_xyz(rlab: Vector, env: Environment) -> Vector: 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) + return alg.matmul_x3(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.matmul(env.ram, xyz, dims=alg.D2_D1) - xr, yr, zr = [alg.nth_root(c, env.surround) for c in xyz_ref] + xyz_ref = alg.matmul_x3(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) bR = 170 * (yr - zr) diff --git a/lib/coloraide/spaces/ryb.py b/lib/coloraide/spaces/ryb.py index e8ced83..abf17e4 100644 --- a/lib/coloraide/spaces/ryb.py +++ b/lib/coloraide/spaces/ryb.py @@ -6,12 +6,12 @@ """ from __future__ import annotations import math -from ..spaces import Regular, Space +from .. import util +from ..spaces import Prism, Space from .. import algebra as alg from ..channels import Channel from ..cat import WHITES from ..types import Vector, Matrix -from ..easing import _bezier, _solve_bezier # In terms of RGB GOSSET_CHEN_CUBE = [ @@ -26,23 +26,73 @@ ] # type: Matrix +def cubic_poly(t: float, a: float, b: float, c: float, d: float) -> float: + """Cubic polynomial.""" + + return a * t ** 3 + b * t ** 2 + c * t + d + + +def cubic_poly_dt(t: float, a: float, b: float, c: float) -> float: + """Derivative of cubic polynomial.""" + + return 3 * a * t ** 2 + 2 * b * t + c + + +def solve_cubic_poly(a: float, b: float, c: float, d: float) -> float: + """ + Solve curve to find a `t` that satisfies our desired `x`. + + Using `alg.solve_poly` is actually faster and more accurate as it is an + analytical approach. Since we are using Newton's method for the inverse + trilinear interpolation, which is only accurate to around 1e-6 in our case, + applying a very accurate cubic solver to a not so accurate inverse interpolation + can actually give us an even more inaccurate result. This is evident in our use + case around RYB [1, 1, 0] which can drop to around 1e-3 accuracy. + + Using an approach where we can better control accuracy and limit it to a similar accuracy + of 1e-6 actually helps us maintain a minimum of 1e-6 accuracy through the sRGB + gamut giving more consistent results within the trilinear cube. + """ + + eps = 1e-6 + maxiter = 8 + + if d <= 0.0 or d >= 1.0: + return d + + # Try Newtons method to see if we can find a suitable value + f0 = lambda t: cubic_poly(t, a, b, c, -d) + dx = lambda t: cubic_poly_dt(t, a, b, c) + t, converged = alg.solve_newton(0.5, f0, dx, maxiter=maxiter, atol=eps) + + # We converged or we are close enough + if converged: + return t + + # Fallback to bisection + return alg.solve_bisect(0.0, 1.0, f0, start=d, atol=eps)[0] + + def srgb_to_ryb(rgb: Vector, cube_t: Matrix, cube: Matrix, biased: bool) -> Vector: """Convert RYB to sRGB.""" # Calculate the RYB value ryb = alg.ilerp3d(cube_t, rgb, vertices_t=cube) # Remove smoothstep easing if "biased" is enabled. - return [_solve_bezier(t, -2, 3, 0) if 0 <= t <= 1 else t for t in ryb] if biased else ryb + return [solve_cubic_poly(-2.0, 3.0, 0.0, t) if 0 <= t <= 1 else t for t in ryb] if biased else ryb def ryb_to_srgb(ryb: Vector, cube_t: Matrix, biased: bool) -> Vector: """Convert RYB to sRGB.""" + # Apply cubic easing function + if biased: + ryb = [cubic_poly(t, -2.0, 3.0, 0.0, 0.0) if 0 <= t <= 1 else t for t in ryb] # Bias interpolation towards corners if "biased" enable. Bias is a smoothstep easing function. - return alg.lerp3d(cube_t, [_bezier(t, -2, 3, 0) if 0 <= t <= 1 else t for t in ryb] if biased else ryb) + return alg.lerp3d(cube_t, ryb) -class RYB(Regular, Space): +class RYB(Prism, Space): """ The RYB color space based on the paper by Gosset and Chen. @@ -66,6 +116,7 @@ class RYB(Regular, Space): RYB_CUBE = GOSSET_CHEN_CUBE RYB_CUBE_T = alg.transpose(RYB_CUBE) BIASED = False + SUBTRACTIVE = True def is_achromatic(self, coords: Vector) -> bool: """ @@ -77,7 +128,7 @@ def is_achromatic(self, coords: Vector) -> bool: coords = self.to_base(coords) for x in alg.vcross(coords, [1, 1, 1]): - if not math.isclose(0.0, x, abs_tol=1e-5): + if not math.isclose(0.0, x, abs_tol=util.ACHROMATIC_THRESHOLD): return False return True diff --git a/lib/coloraide/spaces/srgb/__init__.py b/lib/coloraide/spaces/srgb/__init__.py index 6fa9020..d112eb4 100644 --- a/lib/coloraide/spaces/srgb/__init__.py +++ b/lib/coloraide/spaces/srgb/__init__.py @@ -6,7 +6,7 @@ import math -def lin_srgb(rgb: Vector) -> Vector: +def eotf_srgb(rgb: Vector) -> Vector: """ Convert an array of sRGB values in the range 0.0 - 1.0 to linear light (un-corrected) form. @@ -24,7 +24,7 @@ def lin_srgb(rgb: Vector) -> Vector: return result -def gam_srgb(rgb: Vector) -> Vector: +def inverse_eotf_srgb(rgb: Vector) -> Vector: """ Convert an array of linear-light sRGB values in the range 0.0-1.0 to gamma corrected form. @@ -48,7 +48,6 @@ class sRGB(sRGBLinear): BASE = "srgb-linear" NAME = "srgb" SERIALIZE = ("srgb",) - EXTENDED_RANGE = True def linear(self) -> str: """Return linear version of the RGB (if available).""" @@ -58,9 +57,9 @@ def linear(self) -> str: def from_base(self, coords: Vector) -> Vector: """From sRGB Linear to sRGB.""" - return gam_srgb(coords) + return inverse_eotf_srgb(coords) def to_base(self, coords: Vector) -> Vector: """To sRGB Linear from sRGB.""" - return lin_srgb(coords) + return eotf_srgb(coords) diff --git a/lib/coloraide/spaces/srgb/css.py b/lib/coloraide/spaces/srgb/css.py index dc75e6e..7e81303 100644 --- a/lib/coloraide/spaces/srgb/css.py +++ b/lib/coloraide/spaces/srgb/css.py @@ -3,10 +3,10 @@ from .. import srgb as base from ...css import parse from ...css import serialize -from typing import Any, Tuple, TYPE_CHECKING, Sequence +from typing import Any, Sequence, TYPE_CHECKING from ...types import Vector -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ...color import Color @@ -18,7 +18,8 @@ def to_string( parent: Color, *, alpha: bool | None = None, - precision: int | None = None, + precision: int | Sequence[int] | None = None, + rounding: str | None = None, fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, @@ -37,6 +38,7 @@ def to_string( func='rgb', alpha=alpha, precision=precision, + rounding=rounding, fit=fit, none=none, color=color, @@ -54,7 +56,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Tuple[Tuple[Vector, float], int] | None: + ) -> 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 16148f7..e10f234 100644 --- a/lib/coloraide/spaces/srgb_linear.py +++ b/lib/coloraide/spaces/srgb_linear.py @@ -1,5 +1,6 @@ """sRGB Linear color class.""" from __future__ import annotations +from .. import util from ..cat import WHITES from ..spaces import RGBish, Space from ..channels import Channel @@ -28,13 +29,13 @@ def lin_srgb_to_xyz(rgb: Vector) -> Vector: D65 (no chromatic adaptation) """ - return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul_x3(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_srgb(xyz: Vector) -> Vector: """Convert XYZ to linear-light sRGB.""" - return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul_x3(XYZ_TO_RGB, xyz, dims=alg.D2_D1) class sRGBLinear(RGBish, Space): @@ -59,7 +60,7 @@ def is_achromatic(self, coords: Vector) -> bool: white = [1, 1, 1] for x in alg.vcross(coords, white): - if not math.isclose(0.0, x, abs_tol=1e-5): + if not math.isclose(0.0, x, abs_tol=util.ACHROMATIC_THRESHOLD_SM): return False return True diff --git a/lib/coloraide/spaces/ucs.py b/lib/coloraide/spaces/ucs.py index 34e3416..f2a9f6e 100644 --- a/lib/coloraide/spaces/ucs.py +++ b/lib/coloraide/spaces/ucs.py @@ -4,7 +4,7 @@ http://en.wikipedia.org/wiki/CIE_1960_color_space#Relation_to_CIE_XYZ """ from __future__ import annotations -from ..spaces import Space +from ..spaces import Prism, Luminant, Space from ..channels import Channel from ..cat import WHITES from ..types import Vector @@ -24,7 +24,7 @@ def ucs_to_xyz(ucs: Vector) -> Vector: return [(3 / 2) * u, v, (3 / 2) * u - 3 * v + 2 * w] -class UCS(Space): +class UCS(Luminant, Prism, Space): """The 1960 UCS class.""" BASE = "xyz-d65" @@ -37,6 +37,11 @@ class UCS(Space): ) WHITE = WHITES['2deg']['D65'] + def lightness_name(self) -> str: + """Get lightness name.""" + + return "v" + def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/xyb.py b/lib/coloraide/spaces/xyb.py index 3b64310..887f8b2 100644 --- a/lib/coloraide/spaces/xyb.py +++ b/lib/coloraide/spaces/xyb.py @@ -5,7 +5,7 @@ """ from __future__ import annotations from .. import algebra as alg -from ..spaces import Space, Labish +from ..spaces.lab import Lab from ..types import Vector from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT @@ -25,35 +25,36 @@ [-3.6588512867136815, 2.712923045936092, 1.945928240777589] ] -# https://twitter.com/jonsneyers/status/1605321352143331328 -# @jonsneyers Feb 22 -# Yes, the default is to just subtract Y from B. In general there are locally -# signaled float multipliers to subtract some multiple of Y from X and some -# other multiple from B. But this is the baseline, making X=B=0 grayscale. -# ---- -# We adjust the matrix to subtract Y from B match this statement. XYB_LMS_TO_XYB = [ [0.5, -0.5, 0.0], [0.5, 0.5, 0.0], - [0.0, -1.0, 1.0], + [0.0, 0.0, 1.0] ] XYB_TO_XYB_LMS = [ [1.0, 1.0, 0.0], [-1.0, 1.0, 0.0], - [-1.0, 1.0, 1.0] + [0.0, 0.0, 1.0] ] def rgb_to_xyb(rgb: Vector) -> Vector: """Linear sRGB to XYB.""" - return alg.matmul( + xyb = alg.matmul_x3( XYB_LMS_TO_XYB, - [alg.nth_root(c + BIAS, 3) - BIAS_CBRT for c in alg.matmul(LRGB_TO_LMS, rgb, dims=alg.D2_D1)], + [alg.nth_root(c + BIAS, 3) - BIAS_CBRT for c in alg.matmul_x3(LRGB_TO_LMS, rgb, dims=alg.D2_D1)], dims=alg.D2_D1 ) + # https://twitter.com/jonsneyers/status/1605321352143331328 + # @jonsneyers Feb 22 + # Yes, the default is to just subtract Y from B. In general there are locally + # signaled float multipliers to subtract some multiple of Y from X and some + # other multiple from B. But this is the baseline, making X=B=0 grayscale. + xyb[2] -= xyb[1] + return xyb + def xyb_to_rgb(xyb: Vector) -> Vector: """XYB to linear sRGB.""" @@ -62,14 +63,15 @@ def xyb_to_rgb(xyb: Vector) -> Vector: if not any(xyb): return [0.0] * 3 - return alg.matmul( + xyb[2] += xyb[1] + return alg.matmul_x3( LMS_TO_LRGB, - [(c + BIAS_CBRT) ** 3 - BIAS for c in alg.matmul(XYB_TO_XYB_LMS, xyb, dims=alg.D2_D1)], + [(c + BIAS_CBRT) ** 3 - BIAS for c in alg.matmul_x3(XYB_TO_XYB_LMS, xyb, dims=alg.D2_D1)], dims=alg.D2_D1 ) -class XYB(Labish, Space): +class XYB(Lab): """XYB color class.""" BASE = 'srgb-linear' @@ -82,12 +84,22 @@ class XYB(Labish, Space): Channel("b", -0.45, 0.45, flags=FLG_MIRROR_PERCENT) ) - def names(self) -> tuple[str, ...]: + def is_achromatic(self, coords: Vector) -> bool: + """Check if color is achromatic.""" + + return alg.rect_to_polar(coords[0], coords[2])[0] < self.achromatic_threshold + + def names(self) -> tuple[Channel, ...]: """Return Lab-ish names in the order L a b.""" channels = self.channels return channels[1], channels[0], channels[2] + def lightness_name(self) -> str: + """Get lightness name.""" + + return "y" + def to_base(self, coords: Vector) -> Vector: """To XYB from base.""" diff --git a/lib/coloraide/spaces/xyy.py b/lib/coloraide/spaces/xyy.py index 66234e5..1dd7706 100644 --- a/lib/coloraide/spaces/xyy.py +++ b/lib/coloraide/spaces/xyy.py @@ -4,7 +4,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 ..spaces import Space, Prism, Luminant from ..channels import Channel from ..cat import WHITES from .. import util @@ -13,7 +13,7 @@ import math -class xyY(Space): +class xyY(Luminant, Prism, Space): """The xyY class.""" BASE = "xyz-d65" @@ -32,7 +32,7 @@ def is_achromatic(self, coords: Vector) -> bool: if math.isclose(0.0, coords[-1], abs_tol=1e-4): return True - if not math.isclose(0.0, alg.vcross(coords[:-1], self.WHITE), abs_tol=1e-6): + if not math.isclose(0.0, alg.vcross(coords[:-1], self.WHITE), abs_tol=util.ACHROMATIC_THRESHOLD_SM): return False return True @@ -45,3 +45,8 @@ def from_base(self, coords: Vector) -> Vector: """From XYZ.""" return util.xyz_to_xyY(coords, self.white()) + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "Y" diff --git a/lib/coloraide/spaces/xyz_d65.py b/lib/coloraide/spaces/xyz_d65.py index c12f42d..95a110b 100644 --- a/lib/coloraide/spaces/xyz_d65.py +++ b/lib/coloraide/spaces/xyz_d65.py @@ -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 math.isclose(0.0, x, abs_tol=1e-5): + if not math.isclose(0.0, x, abs_tol=util.ACHROMATIC_THRESHOLD_SM): return False return True diff --git a/lib/coloraide/spaces/zcam.py b/lib/coloraide/spaces/zcam.py new file mode 100644 index 0000000..0967193 --- /dev/null +++ b/lib/coloraide/spaces/zcam.py @@ -0,0 +1,452 @@ +""" +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 ..cat import WHITES +from ..channels import Channel, FLG_ANGLE +from ..types import Vector, VectorLike +from .lch import LCh +from .jzazbz import izazbz_to_xyz, xyz_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 = [ + [1.0961238208355142, -0.27886900021828726, 0.18274517938277304], + [0.45436904197535916, 0.4735331543074118, 0.07209780371722913], + [-0.009627608738429355, -0.00569803121611342, 1.0153256399545427] +] + +# 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.matmul_x3(CAT02, xyz_b, dims=alg.D2_D1) + rgb_wb = alg.matmul_x3(CAT02, xyz_wb, dims=alg.D2_D1) + rgb_wd = alg.matmul_x3(CAT02, xyz_wd, dims=alg.D2_D1) + rgb_wo = alg.matmul_x3(CAT02, xyz_wo, dims=alg.D2_D1) + + d_rgb_wb = alg.add_x3( + alg.multiply_x3(db * yb, alg.divide_x3(rgb_wo, rgb_wb, dims=alg.D1), dims=alg.SC_D1), + 1 - db, + dims=alg.D1_SC + ) + d_rgb_wd = alg.add_x3( + alg.multiply_x3(dd * yd, alg.divide_x3(rgb_wo, rgb_wd, dims=alg.D1), dims=alg.SC_D1), + 1 - dd, + dims=alg.D1_SC + ) + d_rgb = alg.divide_x3(d_rgb_wb, d_rgb_wd, dims=alg.D1) + rgb_d = alg.multiply_x3(d_rgb, rgb_b, dims=alg.D1) + return alg.matmul_x3(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 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 = [*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_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( + 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") + + # Shortcut out if black? + if Jz == 0.0 or Qz == 0.0: + if not any((Cz, Mz, Sz, Vz, Kz, Wz)): + 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 = alg.nth_root((Vz ** 2 - (Jz - 58) ** 2) / 3.4, 2) + elif Kz is not None: + Cz = alg.nth_root((((Kz - 100) / - 0.8) ** 2 - (Jz ** 2)) / 8, 2) + elif Wz is not None: + Cz = alg.nth_root((Wz - 100) ** 2 - (100 - Jz) ** 2, 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([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_to_zcam(xyz: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector: + """From XYZ to ZCAM.""" + + # Steps 4 - 7 + iz, az, bz = xyz_to_izazbz( + adapt(util.xyz_to_absxyz(xyz), 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) * alg.nth_root(Mz / Qz, 2) 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_to_zcam_jmh(xyz: Vector, env: Environment) -> Vector: + """XYZ to ZCAM JMh.""" + + zcam = xyz_to_zcam(xyz, env) + Jz, Mz, hz = zcam[0], zcam[4], zcam[2] + return [Jz, Mz, hz] + + +def zcam_jmh_to_xyz(jmh: Vector, env: Environment) -> Vector: + """ZCAM JMh to XYZ.""" + + Jz, Mz, hz = jmh + return zcam_to_xyz(Jz=Jz, Mz=Mz, hz=hz, env=env) + + +class ZCAMJMh(LCh): + """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 hue_name(self) -> str: + """Hue name.""" + + return "hz" + + def radial_name(self) -> str: + """Radial name.""" + + return "mz" + + def lightness_name(self) -> str: + """Get lightness name.""" + + return "jz" + + def to_base(self, coords: Vector) -> Vector: + """From ZCAM JMh to XYZ.""" + + return zcam_jmh_to_xyz(coords, self.ENV) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ to ZCAM JMh.""" + + return xyz_to_zcam_jmh(coords, self.ENV) diff --git a/lib/coloraide/spaces/zcam_jmh.py b/lib/coloraide/spaces/zcam_jmh.py index a793eeb..e10f0b9 100644 --- a/lib/coloraide/spaces/zcam_jmh.py +++ b/lib/coloraide/spaces/zcam_jmh.py @@ -1,449 +1,13 @@ """ -ZCAM. +Deprecated ZCAM submodule. -``` -- 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 -``` +Users should import from `coloraide.spaces.zcam` instead. """ -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) +from .zcam import * # noqa: F403 +from warnings import warn + +warn( + f'The module {__name__} is deprecated, please use coloraide.spaces.zcam instead.', + DeprecationWarning, + stacklevel=2 +) diff --git a/lib/coloraide/temperature/__init__.py b/lib/coloraide/temperature/__init__.py index 379256a..287b37e 100644 --- a/lib/coloraide/temperature/__init__.py +++ b/lib/coloraide/temperature/__init__.py @@ -1,10 +1,10 @@ """Temperature plugin.""" from __future__ import annotations from abc import ABCMeta, abstractmethod -from ..types import Plugin, Vector -from typing import TYPE_CHECKING, Any +from ..types import Plugin, Vector, AnyColor +from typing import Any, TYPE_CHECKING -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color @@ -20,18 +20,18 @@ def to_cct(self, color: Color, **kwargs: Any) -> Vector: @abstractmethod def from_cct( self, - color: type[Color], + color: type[AnyColor], space: str, kelvin: float, duv: float, scale: bool, scale_space: str | None, **kwargs: Any - ) -> Color: + ) -> AnyColor: """Calculate a color that satisfies the CCT.""" -def cct(name: str | None, color: type[Color] | Color) -> CCT: +def cct(name: str | None, color: type[AnyColor] | AnyColor) -> CCT: """Get the appropriate contrast plugin.""" if name is None: @@ -39,6 +39,6 @@ def cct(name: str | None, color: type[Color] | Color) -> CCT: method = color.CCT_MAP.get(name) if not method: - raise ValueError("'{}' CCT method is not supported".format(name)) + raise ValueError(f"'{name}' CCT method is not supported") return method diff --git a/lib/coloraide/temperature/ohno_2013.py b/lib/coloraide/temperature/ohno_2013.py index 7b26dac..d1e7845 100644 --- a/lib/coloraide/temperature/ohno_2013.py +++ b/lib/coloraide/temperature/ohno_2013.py @@ -11,11 +11,8 @@ from .. import util from .. import algebra as alg from ..temperature import CCT -from ..types import Vector, VectorLike -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from ..types import Vector, VectorLike, AnyColor +from typing import Any class BlackBodyCurve: @@ -38,7 +35,7 @@ def __init__( ) -> None: """Initialize.""" - keys = list(cmfs.keys()) + keys = [*cmfs.keys()] self.cmfs_start = min(keys) self.cmfs_end = max(keys) self.cmfs = cmfs @@ -160,7 +157,7 @@ def __init__( def to_cct( self, - color: Color, + color: AnyColor, start: float = 1000, end: float = 100000, samples: int = 10, @@ -224,7 +221,7 @@ def to_cct( x = (dp ** 2 - dn ** 2 + l ** 2) / (2 * l) t = tp + (tn - tp) * (x / l) vtx = vp + (vn - vp) * (x / l) - sign = math.copysign(1, v - vtx) + sign = alg.sign(v - vtx) duv = (dp ** 2 - x ** 2) ** (1 / 2) * sign # Parabolic solution @@ -252,14 +249,14 @@ def to_cct( def from_cct( self, - color: type[Color], + color: type[AnyColor], space: str, kelvin: float, duv: float, scale: bool, scale_space: str | None, **kwargs: Any - ) -> Color: + ) -> AnyColor: """Calculate a color that satisfies the CCT using Planck's law.""" u0, v0 = self.blackbody(kelvin, exact=True) diff --git a/lib/coloraide/temperature/robertson_1968.py b/lib/coloraide/temperature/robertson_1968.py index d67204b..b8a00ba 100644 --- a/lib/coloraide/temperature/robertson_1968.py +++ b/lib/coloraide/temperature/robertson_1968.py @@ -14,10 +14,11 @@ from .. import cat from .. import cmfs from ..temperature import CCT -from ..types import Vector, VectorLike -from typing import TYPE_CHECKING, Any +from ..types import Vector, VectorLike, AnyColor +from typing import Any, TYPE_CHECKING +from dataclasses import dataclass -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: #pragma: no cover from ..color import Color # Original 31 mired points 0 - 600 @@ -26,6 +27,19 @@ MIRED_EXTENDED = MIRED_ORIGINAL + tuple(range(625, 1001, 25)) +@dataclass(frozen=True) +class CCTEntry: + """CCT LUT entry.""" + + mired: float + u: float + v: float + slope: float + slope_length: float + du: float + dv: float + + class Robertson1968(CCT): """Delta E plugin class.""" @@ -52,7 +66,7 @@ def generate_table( mired: VectorLike, sigfig: int, planck_step: int, - ) -> list[tuple[float, float, float, float]]: + ) -> list[CCTEntry]: """ Generate the necessary table for the Robertson1968 method. @@ -67,10 +81,15 @@ def generate_table( We are able to calculate the uv pair for each mired point directly except for 0. 0 requires us to interpolate the values as it will cause a divide by zero in the Planckian locus. In this case, we assume a perfect 0.5 (middle) for our interpolation. + + Additionally, we precalculate a few other things to save time: + - slope length of unit vector + - u component of slope unit vector + - v component of slope unit vector """ xyzw = util.xy_to_xyz(white) - table = [] # type: list[tuple[float, float, float, float]] + table = [] # type: list[CCTEntry] 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)) @@ -83,96 +102,127 @@ def generate_table( d1 = math.sqrt((uv[1] - uv1[1]) ** 2 + (uv[0] - uv1[0]) ** 2) d2 = math.sqrt((uv2[1] - uv[1]) ** 2 + (uv2[0] - uv[0]) ** 2) factor = d1 / (d1 + d2) - m1 = -((uv[1] - uv1[1]) / (uv[0] - uv1[0])) ** -1 - m2 = -((uv2[1] - uv[1]) / (uv2[0] - uv[0])) ** -1 + + # Attempt to calculate the slope, if it falls exactly where the slope switch, + # There will be a divide by zero, just skip this location. + try: + m1 = -((uv[1] - uv1[1]) / (uv[0] - uv1[0])) ** -1 + m2 = -((uv2[1] - uv[1]) / (uv2[0] - uv[0])) ** -1 + except ZeroDivisionError: # pragma: no cover + continue + m = alg.lerp(m1, m2, factor) if sigfig: - template = '{{:.{}g}}'.format(sigfig) + template = f'{{:.{sigfig}g}}' + slope = float(template.format(m)) + length = math.sqrt(1 + slope * slope) + table.append( - ( + CCTEntry( float(t), float(template.format(uv[0])), float(template.format(uv[1])), - float(template.format(m)) + slope, + length, + 1 / length, + slope / length ) ) else: - table.append((t, uv[0], uv[1], m)) + length = math.sqrt(1 + m * m) + table.append(CCTEntry(t, uv[0], uv[1], m, length, 1 / length, m / length)) return table + def calc_du_dv( + self, + previous: CCTEntry, + current: CCTEntry, + factor: float + ) -> tuple[float, float]: + """Calculate the Duv.""" + + pslope = previous.slope + slope = current.slope + u1 = previous.du + v1 = previous.dv + u2 = current.du + v2 = current.dv + + # Check for discontinuity and adjust accordingly + if (pslope * slope) < 0: + u2 *= -1 + v2 *= -1 + + # Find vector from the locus to our point. + du = alg.lerp(u2, u1, factor) + dv = alg.lerp(v2, v1, factor) + length = math.sqrt(du ** 2 + dv ** 2) + du /= length + dv /= length + + return du, dv + def to_cct(self, color: Color, **kwargs: Any) -> Vector: """Calculate a color's CCT.""" + dip = kelvin = duv = 0.0 + sign = -1 u, v = color.split_chromaticity(self.CHROMATICITY)[:-1] end = len(self.table) - 1 - slope_invert = False # Search for line pair coordinate is between. - previous_di = temp = duv = 0.0 - for index, current in enumerate(self.table): # Get the distance # If a table was generated with values down to 1000K, # we would get a positive slope, so to keep logic the # same, adjust distance calculation such that negative # is still what we are looking for. - if current[3] < 0: - di = (v - current[2]) - current[3] * (u - current[1]) + slope = current.slope + if slope < 0: + di = (v - current.v) - slope * (u - current.u) else: - slope_invert = True - di = (current[2] - v) - current[3] * (current[1] - u) + di = (current.v - v) - slope * (current.u - u) + if index > 0 and (di <= 0.0 or index == end): # Calculate the required interpolation factor between the two lines previous = self.table[index - 1] - current_denom = math.sqrt(1.0 + current[3] ** 2) - di /= current_denom - previous_denom = math.sqrt(1.0 + previous[3] ** 2) - dip = previous_di / previous_denom + di /= current.slope_length + dip /= previous.slope_length factor = dip / (dip - di) - # 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 math.inf - - # Interpolate the slope vectors - dup = 1 / previous_denom - dvp = previous[3] / previous_denom - du = 1 / current_denom - dv = current[3] / current_denom - du = alg.lerp(dup, du, factor) - dv = alg.lerp(dvp, dv, factor) - denom = math.sqrt(du ** 2 + dv ** 2) - du /= denom - dv /= denom + # Calculate the temperature. If the mired value is zero, assume infinity. + pmired = previous.mired + mired = (pmired - factor * (pmired - current.mired)) + kelvin = 1.0E6 / mired if mired else math.inf # Calculate Duv - duv = ( - du * (u - alg.lerp(previous[1], current[1], factor)) + - dv * (v - alg.lerp(previous[2], current[2], factor)) + du, dv = self.calc_du_dv(previous, current, 1 - factor) + duv = sign * ( + du * (u - alg.lerp(previous.u, current.u, factor)) + + dv * (v - alg.lerp(previous.v, current.v, factor)) ) break # Save distance as previous - previous_di = di + dip = di - return [temp, -duv if duv and not slope_invert else duv] + return [kelvin, duv] def from_cct( self, - color: type[Color], + color: type[AnyColor], space: str, kelvin: float, duv: float, scale: bool, scale_space: str | None, **kwargs: Any - ) -> Color: + ) -> AnyColor: """Calculate a color that satisfies the CCT.""" # Find inverse temperature to use as index. - r = 1.0E6 / kelvin + mired = 1.0E6 / kelvin u = v = 0.0 end = len(self.table) - 2 @@ -180,41 +230,25 @@ def from_cct( future = self.table[index + 1] # Find the two isotherms that our target temp is between - if r < future[0] or index == end: + future_mired = future.mired + if mired < future_mired or index == end: # Find relative weight between the two values - f = (future[0] - r) / (future[0] - current[0]) + f = (future_mired - mired) / (future_mired - current.mired) # Interpolate the uv coordinates of our target temperature - u = alg.lerp(future[1], current[1], f) - v = alg.lerp(future[2], current[2], f) + u = alg.lerp(future.u, current.u, f) + v = alg.lerp(future.v, current.v, f) # Calculate the offset along the slope if duv: - slope_invert = current[3] >= 0 - - # Calculate the slope vectors - u1 = 1.0 - v1 = current[3] - length = math.sqrt(1.0 + v1 ** 2) - u1 /= length - v1 /= length - - u2 = 1.0 - v2 = future[3] - length = math.sqrt(1.0 + v2 ** 2) - u2 /= length - v2 /= length - - # Find vector from the locus to our point. - du = alg.lerp(u2, u1, f) - dv = alg.lerp(v2, v1, f) - denom = math.sqrt(du ** 2 + dv ** 2) - du /= denom - dv /= denom + # Calculate the sign + slope = future.slope + sign = 1.0 if not (slope * current.slope) < 0 and slope >= 0 else -1.0 # Adjust the uv by the calculated offset - u += du * (-duv if not slope_invert else duv) - v += dv * (-duv if not slope_invert else duv) + du, dv = self.calc_du_dv(current, future, f) + u += du * sign * duv + v += dv * sign * duv break return color.chromaticity(space, [u, v, 1], self.CHROMATICITY, scale=scale, scale_space=scale_space) diff --git a/lib/coloraide/types.py b/lib/coloraide/types.py index 2659060..8df7ec8 100644 --- a/lib/coloraide/types.py +++ b/lib/coloraide/types.py @@ -1,44 +1,59 @@ +# noqa: A005 """Typing.""" from __future__ import annotations +import sys from typing import Union, Any, Mapping, Sequence, List, Tuple, TypeVar, TYPE_CHECKING - +if (3, 11) <= sys.version_info: + from typing import Unpack +else: + from typing_extensions import Unpack if TYPE_CHECKING: # pragma: no cover from .color import Color +# Generic color template for handling inherited colors +AnyColor = TypeVar('AnyColor', bound='Color') + +# Color inputs which can be an object, string, or a mapping describing the color. ColorInput = Union['Color', str, Mapping[str, Any]] # Vectors, Matrices, and Arrays are assumed to be mutable lists Vector = List[float] Matrix = List[Vector] -Tensor = List[List[List[Union[float, Any]]]] +Tensor = List[Union[Matrix, 'Tensor']] 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] -TensorLike = Sequence[Sequence[Sequence[Union[float, Any]]]] +TensorLike = Sequence[Union[MatrixLike, 'TensorLike']] 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]]]] +TensorBool = List[Union[MatrixBool, 'TensorBool']] ArrayBool = Union[MatrixBool, VectorBool, TensorBool] VectorInt = List[int] MatrixInt = List[VectorInt] -TensorInt = List[List[List[Union[int, Any]]]] +TensorInt = List[Union[MatrixInt, 'TensorInt']] ArrayInt = Union[MatrixInt, VectorInt, TensorInt] # General algebra types -Shape = Tuple[int, ...] +EmptyShape = Tuple[()] +VectorShape = Tuple[int] +MatrixShape = Tuple[int, int] +TensorShape = Tuple[int, int, int, Unpack[Tuple[int, ...]]] + +ArrayShape = Tuple[int, ...] +Shape = Union[EmptyShape, ArrayShape] ShapeLike = Sequence[int] DimHints = Tuple[int, int] # For times when we must explicitly say we support `int` and `float` SupportsFloatOrInt = TypeVar('SupportsFloatOrInt', float, int) -MathType = TypeVar('MathType', float, VectorLike, MatrixLike, TensorLike) +ArrayType = TypeVar('ArrayType', float, VectorLike, MatrixLike, TensorLike) class Plugin: diff --git a/lib/coloraide/util.py b/lib/coloraide/util.py index 1d4b896..2b17217 100644 --- a/lib/coloraide/util.py +++ b/lib/coloraide/util.py @@ -4,9 +4,10 @@ from functools import wraps from . import algebra as alg from .types import Vector, VectorLike -from typing import Any, Callable +from typing import Any, Callable, Sequence DEF_PREC = 5 +DEF_ROUND_MODE = 'digits' DEF_FIT_TOLERANCE = 0.000075 DEF_ALPHA = 1.0 DEF_MIX = 0.5 @@ -21,6 +22,9 @@ DEF_CCT = "robertson-1968" DEF_INTERPOLATOR = "linear" +ACHROMATIC_THRESHOLD = 1e-4 +ACHROMATIC_THRESHOLD_SM = 1e-6 + # PQ Constants # https://en.wikipedia.org/wiki/High-dynamic-range_video#Perceptual_quantizer M1 = 2610 / 16384 @@ -74,13 +78,7 @@ def xy_to_uv_1960(xy: VectorLike) -> Vector: x, y = xy denom = (12 * y - 2 * x + 3) - if denom != 0: - u = (4 * x) / denom - v = (6 * y) / denom - else: - u = v = 0 - - return [u, v] + return [0.0, 0.0] if denom == 0 else [(4 * x) / denom, (6 * y) / denom] def uv_1960_to_xy(uv: VectorLike) -> Vector: @@ -88,16 +86,10 @@ def uv_1960_to_xy(uv: VectorLike) -> Vector: u, v = uv denom = (2 * u - 8 * v + 4) - if denom != 0: - x = (3 * u) / denom - y = (2 * v) / denom - else: - x = y = 0 + return [0.0, 0.0] if denom == 0 else [(3 * u) / denom, (2 * v) / denom] - return [x, y] - -def pq_st2084_oetf( +def inverse_eotf_st2084( values: VectorLike, c1: float = C1, c2: float = C2, @@ -105,7 +97,7 @@ def pq_st2084_oetf( m1: float = M1, m2: float = M2 ) -> Vector: - """Perceptual quantizer (SMPTE ST 2084) - OETF.""" + """Perceptual quantizer (SMPTE ST 2084) - inverse EOTF.""" adjusted = [] for c in values: @@ -114,7 +106,7 @@ def pq_st2084_oetf( return adjusted -def pq_st2084_eotf( +def eotf_st2084( values: VectorLike, c1: float = C1, c2: float = C2, @@ -161,7 +153,7 @@ def scale100(coords: Vector) -> Vector: def scale1(coords: Vector) -> Vector: - """Scale from 1 to 100.""" + """Scale from 100 to 1.""" return [c * 0.01 for c in coords] @@ -184,6 +176,15 @@ def constrain_hue(hue: float) -> float: return hue % 360 if not math.isnan(hue) else hue +def get_index(obj: Sequence[Any], idx: int, default: Any = None) -> Any: + """Get sequence value at index or return default if not present.""" + + try: + return obj[idx] + except IndexError: + return default + + def cmp_coords(c1: VectorLike, c2: VectorLike) -> bool: """Compare coordinates.""" @@ -193,31 +194,40 @@ def cmp_coords(c1: VectorLike, c2: VectorLike) -> bool: return all(map(lambda a, b: (math.isnan(a) and math.isnan(b)) or a == b, c1, c2)) -def fmt_float(f: float, p: int = 0, percent: float = 0.0, offset: float = 0.0) -> str: +def fmt_float(f: float, p: int = 0, rounding: str = 'digits', percent: float = 0.0, offset: float = 0.0) -> str: """ Set float precision and trim precision zeros. - 0: Round to whole integer - -1: Full precision - : precision level + - `p`: Rounding precision. + + - `rounding`: Specify specific rounding mode. + + - `percent`: Treat as a percent. + + - `offset`: Apply an offset (used in conjunction with `percent`). + """ + # Undefined values should be none if math.isnan(f): return "none" - value = alg.round_to((f + offset) / (percent * 0.01) if percent else f, p) - if p == -1: - p = 17 # ~double precision + # Infinite values do not get rounded + if not math.isfinite(f): + raise ValueError(f'Cannot format non-finite number {f}') - # 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) + # Apply rounding + f = (f + offset) / (percent * 0.01) if percent else f + start, p = alg._round_location(f, p, rounding) + value = alg.round_half_up(f, p) - # Format the string - s = '{{:{}{}f}}'.format('' if whole >= p else '.', p).format(value).rstrip('0').rstrip('.') + # Format the string. + if (p - start + 1) > 17: + # If we are outputting numbers beyond 17 digits, just use normal output. + s = str(value).removesuffix('.0') + else: + # Avoid scientific notation for numbers with 17 digits (double-precision number of decimals). + s = f"{{:0.{1 if p < 1 else p}f}}".format(value).rstrip('0').rstrip('.') return s + '%' if percent else s diff --git a/messages.json b/messages.json index 377e3e7..26ca875 100644 --- a/messages.json +++ b/messages.json @@ -1,4 +1,4 @@ { "install": "messages/install.md", - "6.4.0": "messages/recent.md" + "6.5.0": "messages/recent.md" } diff --git a/messages/recent.md b/messages/recent.md index c057d25..7abc39f 100644 --- a/messages/recent.md +++ b/messages/recent.md @@ -10,12 +10,7 @@ A restart of Sublime Text is **strongly** encouraged. Please report any issues as we _might_ have missed some required updates related to the upgrade to stable `coloraide`. -## 6.4.0 +## 6.5.0 -- **NEW**: Opt into Python 3.8. -- **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. -- **NEW**: Add color rule for `ini` files. -- **FIX**: Fix Less rule. +- **NEW**: Upgrade ColorAide to 6.0.0. +- **NEW**: Require Python 3.13 for Sublim Text 4201+. diff --git a/support.py b/support.py index 54ddc2d..5c6b17f 100644 --- a/support.py +++ b/support.py @@ -5,7 +5,7 @@ import webbrowser import re -__version__ = "6.4.5" +__version__ = "6.5.0" __pc_name__ = 'ColorHelper' CSS = '''