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 = '''