From ec905e5a5d6cf55ba2f9564b425bbf41907dad1e Mon Sep 17 00:00:00 2001 From: Isaac Muse Date: Wed, 6 Dec 2023 15:00:11 -0700 Subject: [PATCH 1/8] Fix missing math.isclose (#261) * Fix missing math.isclose * Fix other cases of isclose --- CHANGES.md | 6 +++++ lib/coloraide/algebra.py | 13 +++++++---- lib/coloraide/gamut/fit_lch_chroma.py | 6 ++++- lib/coloraide/spaces/cmy.py | 2 +- lib/coloraide/spaces/cmyk.py | 4 ++-- lib/coloraide/spaces/prismatic.py | 4 ++-- lib/coloraide/spaces/ryb.py | 2 +- lib/coloraide/spaces/srgb/__init__.py | 2 +- lib/coloraide/spaces/xyy.py | 2 +- lib/coloraide/spaces/xyz_d65.py | 2 +- mkdocs.yml | 33 +++++++++++++++++++++++++-- support.py | 2 +- 12 files changed, 61 insertions(+), 17 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0d0af7e..93df731 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,11 @@ # ColorHelper +## 6.3.2 + +- **FIX**: Fix missing requirement for `math.isclose` in ColorAide + (Python 3.3). +- **FIX**: Do not pad preview by default due to performance impact. + ## 6.3.1 - **FIX**: Update to ColorAide 2.9.1 which uses the exact CSS HWB diff --git a/lib/coloraide/algebra.py b/lib/coloraide/algebra.py index db42415..f94f23e 100644 --- a/lib/coloraide/algebra.py +++ b/lib/coloraide/algebra.py @@ -86,6 +86,11 @@ def prod(values: Iterable[SupportsFloatOrInt]) -> SupportsFloatOrInt: ################################ # General math ################################ +def _math_isclose(a, b, rel_tol=1e-9, abs_tol=0.0): + """Test if values are close.""" + + return abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol) + @deprecated("Please use math.isnan or alg.isnan for a generic approach for vectors and matrices") def is_nan(obj: float) -> bool: """Check if "not a number".""" @@ -550,12 +555,12 @@ def monotone(p0: float, p1: float, p2: float, p3: float, t: float) -> float: m2 = (s1 + s2) * 0.5 # Center segment should be horizontal as there is no increase/decrease between the two points - if math.isclose(p1, p2): + if _math_isclose(p1, p2): m1 = m2 = 0.0 else: # Gradient is zero if segment is horizontal or if the left hand secant differs in sign from current. - if math.isclose(p0, p1) or (math.copysign(1.0, s0) != math.copysign(1.0, s1)): + if _math_isclose(p0, p1) or (math.copysign(1.0, s0) != math.copysign(1.0, s1)): m1 = 0.0 # Ensure gradient magnitude is either 3 times the left or current secant (smaller being preferred). @@ -563,7 +568,7 @@ def monotone(p0: float, p1: float, p2: float, p3: float, t: float) -> float: m1 *= min(3.0 * s0 / m1, min(3.0 * s1 / m1, 1.0)) # Gradient is zero if segment is horizontal or if the right hand secant differs in sign from current. - if math.isclose(p2, p3) or (math.copysign(1.0, s1) != math.copysign(1.0, s2)): + if _math_isclose(p2, p3) or (math.copysign(1.0, s1) != math.copysign(1.0, s2)): m2 = 0.0 # Ensure gradient magnitude is either 3 times the current or right secant (smaller being preferred). @@ -1773,7 +1778,7 @@ def linspace(start: Union[ArrayLike, float], stop: Union[ArrayLike, float], num: def _isclose(a: float, b: float, *, equal_nan: bool = False, **kwargs: Any) -> bool: """Check if values are close.""" - close = math.isclose(a, b, **kwargs) + close = _math_isclose(a, b, **kwargs) return (math.isnan(a) and math.isnan(b)) if not close and equal_nan else close diff --git a/lib/coloraide/gamut/fit_lch_chroma.py b/lib/coloraide/gamut/fit_lch_chroma.py index a86954c..b19d37e 100644 --- a/lib/coloraide/gamut/fit_lch_chroma.py +++ b/lib/coloraide/gamut/fit_lch_chroma.py @@ -3,6 +3,7 @@ from ..cat import WHITES from .. import util import math +from .. import algebra as alg from typing import TYPE_CHECKING, Any, Dict if TYPE_CHECKING: # pragma: no cover @@ -50,7 +51,10 @@ def fit(self, color: 'Color', **kwargs: Any) -> None: # 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)): + if ( + sdr and + (lightness >= self.MAX_LIGHTNESS or alg.isclose(lightness, self.MAX_LIGHTNESS, abs_tol=1e-6, dims=alg.SC)) + ): clip_channels(color.update('xyz-d65', WHITE, mapcolor[-1])) return elif lightness <= self.MIN_LIGHTNESS: diff --git a/lib/coloraide/spaces/cmy.py b/lib/coloraide/spaces/cmy.py index 4c86e18..563e546 100644 --- a/lib/coloraide/spaces/cmy.py +++ b/lib/coloraide/spaces/cmy.py @@ -43,7 +43,7 @@ def is_achromatic(self, coords: Vector) -> bool: black = [1, 1, 1] for x in alg.vcross(coords, black): - if not math.isclose(0.0, x, abs_tol=1e-4): + if not alg.isclose(0.0, x, abs_tol=1e-4, dims=algs.SC): return False return True diff --git a/lib/coloraide/spaces/cmyk.py b/lib/coloraide/spaces/cmyk.py index 10eaee0..1cdd150 100644 --- a/lib/coloraide/spaces/cmyk.py +++ b/lib/coloraide/spaces/cmyk.py @@ -60,12 +60,12 @@ class CMYK(Space): def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" - if math.isclose(1.0, coords[-1], abs_tol=1e-4): + if alg.isclose(1.0, coords[-1], abs_tol=1e-4, dims=alg.SC): 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 alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): return False return True diff --git a/lib/coloraide/spaces/prismatic.py b/lib/coloraide/spaces/prismatic.py index 1c795c4..364b829 100644 --- a/lib/coloraide/spaces/prismatic.py +++ b/lib/coloraide/spaces/prismatic.py @@ -56,12 +56,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 alg.isclose(0.0, coords[0], abs_tol=1e-4, dims=alg.SC): 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 alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): return False return True diff --git a/lib/coloraide/spaces/ryb.py b/lib/coloraide/spaces/ryb.py index a80841e..1dd3d46 100644 --- a/lib/coloraide/spaces/ryb.py +++ b/lib/coloraide/spaces/ryb.py @@ -76,7 +76,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 alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): return False return True diff --git a/lib/coloraide/spaces/srgb/__init__.py b/lib/coloraide/spaces/srgb/__init__.py index fa8d4e0..227dc1e 100644 --- a/lib/coloraide/spaces/srgb/__init__.py +++ b/lib/coloraide/spaces/srgb/__init__.py @@ -67,7 +67,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 alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): return False return True diff --git a/lib/coloraide/spaces/xyy.py b/lib/coloraide/spaces/xyy.py index eef7853..e079ed7 100644 --- a/lib/coloraide/spaces/xyy.py +++ b/lib/coloraide/spaces/xyy.py @@ -29,7 +29,7 @@ class xyY(Space): def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" - if math.isclose(0.0, coords[-1], abs_tol=1e-4): + if alg.isclose(0.0, coords[-1], abs_tol=1e-4, dims=alg.SC): return True for x in alg.vcross(coords[:-1], self.WHITE): diff --git a/lib/coloraide/spaces/xyz_d65.py b/lib/coloraide/spaces/xyz_d65.py index 51f0733..8b9d3bb 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 alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): return False return True diff --git a/mkdocs.yml b/mkdocs.yml index 2298013..ca62f76 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,8 +78,8 @@ markdown_extensions: - pymdownx.caret: - pymdownx.smartsymbols: - pymdownx.emoji: - emoji_index: !!python/name:materialx.emoji.twemoji - emoji_generator: !!python/name:materialx.emoji.to_svg + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg - pymdownx.escapeall: hardbreak: true nbsp: true @@ -110,6 +110,35 @@ markdown_extensions: - example - quote - pymdownx.blocks.details: + types: + - name: details-new + class: new + - name: details-settings + class: settings + - name: details-note + class: note + - name: details-abstract + class: abstract + - name: details-info + class: info + - name: details-tip + class: tip + - name: details-success + class: success + - name: details-question + class: question + - name: details-warning + class: warning + - name: details-failure + class: failure + - name: details-danger + class: danger + - name: details-bug + class: bug + - name: details-example + class: example + - name: details-quote + class: quote - pymdownx.blocks.html: - pymdownx.blocks.definition: - pymdownx.blocks.tab: diff --git a/support.py b/support.py index 5258e01..29de5be 100644 --- a/support.py +++ b/support.py @@ -5,7 +5,7 @@ import webbrowser import re -__version__ = "6.3.1" +__version__ = "6.3.2" __pc_name__ = 'ColorHelper' CSS = ''' From 7de0fedd9f880a9cf9de8d01ca52ee048c6d31f9 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Fri, 12 Apr 2024 14:06:15 -0600 Subject: [PATCH 2/8] Fix less rule --- color_helper.sublime-settings | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/color_helper.sublime-settings b/color_helper.sublime-settings index aedc5d3..48b6768 100755 --- a/color_helper.sublime-settings +++ b/color_helper.sublime-settings @@ -501,7 +501,8 @@ "scanning": [ "constant.other.color.rgb-value.css", "constant.color.w3c-standard-color-name.css", - "meta.property-value.css" + "meta.property-value.css", + "support.function.color.css" ] }, { From 1242ab5facab15d129b02fe09492614f45234dc7 Mon Sep 17 00:00:00 2001 From: deathaxe Date: Sat, 18 May 2024 22:35:21 +0200 Subject: [PATCH 3/8] Opt-in to python 3.8 plugin host (#266) This commit adds `.python-version` file to migrate the plugin to python 3.8 --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..98fccd6 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.8 \ No newline at end of file From 4e86272a5189cb01d1a92f429731e16dbcd569d8 Mon Sep 17 00:00:00 2001 From: Isaac Muse Date: Sat, 18 May 2024 16:13:57 -0600 Subject: [PATCH 4/8] Upgrade coloraide (#267) * Upgrade coloraide * Upgrade to latest coloraide * Update build configs * Fix lint issues * make note of new gamut map method * Update color space list in settings --- .github/workflows/build.yml | 6 +- .github/workflows/deploy.yml | 2 +- CHANGES.md | 7 + ch_picker.py | 4 +- color_helper.sublime-settings | 9 +- custom/ahex.py | 6 +- custom/ass_abgr.py | 6 +- custom/hex_0x.py | 8 +- custom/st_colormod.py | 14 +- custom/tmtheme.py | 6 +- docs/src/markdown/settings/previews.md | 2 +- lib/coloraide/__meta__.py | 5 +- lib/coloraide/algebra.py | 912 ++++++++++++------- lib/coloraide/average.py | 19 +- lib/coloraide/cat.py | 41 +- lib/coloraide/channels.py | 20 +- lib/coloraide/color.py | 343 ++++--- lib/coloraide/compositing/__init__.py | 35 +- lib/coloraide/compositing/blend_modes.py | 4 +- lib/coloraide/compositing/porter_duff.py | 4 +- lib/coloraide/contrast/__init__.py | 7 +- lib/coloraide/contrast/lstar.py | 3 +- lib/coloraide/contrast/wcag21.py | 3 +- lib/coloraide/convert.py | 18 +- lib/coloraide/css/color_names.py | 10 +- lib/coloraide/css/parse.py | 41 +- lib/coloraide/css/serialize.py | 135 +-- lib/coloraide/deprecate.py | 5 +- lib/coloraide/distance/__init__.py | 31 +- lib/coloraide/distance/delta_e_2000.py | 63 +- lib/coloraide/distance/delta_e_76.py | 24 +- lib/coloraide/distance/delta_e_94.py | 29 +- lib/coloraide/distance/delta_e_99o.py | 17 +- lib/coloraide/distance/delta_e_cam16.py | 36 +- lib/coloraide/distance/delta_e_cmc.py | 26 +- lib/coloraide/distance/delta_e_hct.py | 30 +- lib/coloraide/distance/delta_e_hyab.py | 5 +- lib/coloraide/distance/delta_e_itp.py | 5 +- lib/coloraide/distance/delta_e_ok.py | 17 +- lib/coloraide/distance/delta_e_z.py | 3 +- lib/coloraide/easing.py | 19 +- lib/coloraide/everything.py | 19 +- lib/coloraide/filters/__init__.py | 17 +- lib/coloraide/filters/cvd.py | 45 +- lib/coloraide/filters/w3c_filter_effects.py | 29 +- lib/coloraide/gamut/__init__.py | 44 +- lib/coloraide/gamut/fit_hct_chroma.py | 6 +- lib/coloraide/gamut/fit_lch_chroma.py | 48 +- lib/coloraide/gamut/fit_lch_raytrace.py | 9 + lib/coloraide/gamut/fit_oklch_chroma.py | 2 + lib/coloraide/gamut/fit_oklch_raytrace.py | 9 + lib/coloraide/gamut/fit_raytrace.py | 269 ++++++ lib/coloraide/gamut/pointer.py | 21 +- lib/coloraide/harmonies.py | 124 +-- lib/coloraide/interpolate/__init__.py | 270 ++---- lib/coloraide/interpolate/bspline.py | 39 +- lib/coloraide/interpolate/bspline_natural.py | 40 +- lib/coloraide/interpolate/catmull_rom.py | 40 +- lib/coloraide/interpolate/continuous.py | 157 +++- lib/coloraide/interpolate/css_linear.py | 91 ++ lib/coloraide/interpolate/linear.py | 102 ++- lib/coloraide/interpolate/monotone.py | 40 +- lib/coloraide/spaces/__init__.py | 146 +-- lib/coloraide/spaces/a98_rgb.py | 16 +- lib/coloraide/spaces/a98_rgb_linear.py | 11 +- lib/coloraide/spaces/aces2065_1.py | 12 +- lib/coloraide/spaces/acescc.py | 13 +- lib/coloraide/spaces/acescct.py | 13 +- lib/coloraide/spaces/acescg.py | 12 +- lib/coloraide/spaces/achromatic.py | 190 ---- lib/coloraide/spaces/cam16.py | 85 -- lib/coloraide/spaces/cam16_jmh.py | 318 ++----- lib/coloraide/spaces/cam16_ucs.py | 89 +- lib/coloraide/spaces/cmy.py | 10 +- lib/coloraide/spaces/cmyk.py | 8 +- lib/coloraide/spaces/cubehelix.py | 13 +- lib/coloraide/spaces/din99o.py | 11 +- lib/coloraide/spaces/display_p3.py | 13 +- lib/coloraide/spaces/display_p3_linear.py | 11 +- lib/coloraide/spaces/hct.py | 230 ++--- lib/coloraide/spaces/hpluv.py | 40 +- lib/coloraide/spaces/hsi.py | 14 +- lib/coloraide/spaces/hsl/__init__.py | 30 +- lib/coloraide/spaces/hsl/css.py | 28 +- lib/coloraide/spaces/hsluv.py | 37 +- lib/coloraide/spaces/hsv.py | 30 +- lib/coloraide/spaces/hunter_lab.py | 5 +- lib/coloraide/spaces/hwb/__init__.py | 9 +- lib/coloraide/spaces/hwb/css.py | 18 +- lib/coloraide/spaces/ictcp.py | 36 +- lib/coloraide/spaces/igpgtg.py | 69 +- lib/coloraide/spaces/ipt.py | 73 +- lib/coloraide/spaces/jzazbz.py | 232 +---- lib/coloraide/spaces/jzczhz.py | 42 +- lib/coloraide/spaces/lab/__init__.py | 52 +- lib/coloraide/spaces/lab/css.py | 17 +- lib/coloraide/spaces/lab_d65.py | 5 +- lib/coloraide/spaces/lch/__init__.py | 44 +- lib/coloraide/spaces/lch/css.py | 17 +- lib/coloraide/spaces/lch99o.py | 3 +- lib/coloraide/spaces/lch_d65.py | 7 +- lib/coloraide/spaces/lchuv.py | 3 +- lib/coloraide/spaces/luv.py | 6 +- lib/coloraide/spaces/okhsl.py | 42 +- lib/coloraide/spaces/okhsv.py | 16 +- lib/coloraide/spaces/oklab/__init__.py | 17 +- lib/coloraide/spaces/oklab/css.py | 15 +- lib/coloraide/spaces/oklch/__init__.py | 7 +- lib/coloraide/spaces/oklch/css.py | 15 +- lib/coloraide/spaces/orgb.py | 9 +- lib/coloraide/spaces/prismatic.py | 10 +- lib/coloraide/spaces/prophoto_rgb.py | 12 +- lib/coloraide/spaces/prophoto_rgb_linear.py | 9 +- lib/coloraide/spaces/rec2020.py | 12 +- lib/coloraide/spaces/rec2020_linear.py | 9 +- lib/coloraide/spaces/rec2100_hlg.py | 21 +- lib/coloraide/spaces/rec2100_linear.py | 16 + lib/coloraide/spaces/rec2100_pq.py | 25 +- lib/coloraide/spaces/rec709.py | 10 +- lib/coloraide/spaces/rlab.py | 42 +- lib/coloraide/spaces/ryb.py | 3 +- lib/coloraide/spaces/srgb/__init__.py | 30 +- lib/coloraide/spaces/srgb/css.py | 15 +- lib/coloraide/spaces/srgb_linear.py | 31 +- lib/coloraide/spaces/ucs.py | 1 + lib/coloraide/spaces/xyb.py | 12 +- lib/coloraide/spaces/xyy.py | 9 +- lib/coloraide/spaces/xyz_d50.py | 1 + lib/coloraide/spaces/xyz_d65.py | 6 +- lib/coloraide/spaces/zcam_jmh.py | 449 +++++++++ lib/coloraide/temperature/__init__.py | 13 +- lib/coloraide/temperature/ohno_2013.py | 21 +- lib/coloraide/temperature/planck.py | 4 +- lib/coloraide/temperature/robertson_1968.py | 21 +- lib/coloraide/types.py | 20 +- lib/coloraide/util.py | 98 +- tox.ini | 2 +- 137 files changed, 3594 insertions(+), 2848 deletions(-) create mode 100644 lib/coloraide/gamut/fit_lch_raytrace.py create mode 100644 lib/coloraide/gamut/fit_oklch_raytrace.py create mode 100644 lib/coloraide/gamut/fit_raytrace.py create mode 100644 lib/coloraide/interpolate/css_linear.py delete mode 100644 lib/coloraide/spaces/achromatic.py delete mode 100644 lib/coloraide/spaces/cam16.py create mode 100644 lib/coloraide/spaces/rec2100_linear.py create mode 100644 lib/coloraide/spaces/zcam_jmh.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c9291d..97c0626 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index db89678..a90f871 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python diff --git a/CHANGES.md b/CHANGES.md index 93df731..5b6cf18 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # ColorHelper +## 6.4 + +- **NEW**: Upgrade ColorAide. +- **NEW**: Note in documentation and settings a new gamut mapping + method, `oklch-raytrace`, which does a chroma reduction much + faster and closer than the current suggested CSS algorithm. + ## 6.3.2 - **FIX**: Fix missing requirement for `math.isclose` in ColorAide diff --git a/ch_picker.py b/ch_picker.py index 4fb77e6..a31f43b 100644 --- a/ch_picker.py +++ b/ch_picker.py @@ -109,7 +109,7 @@ def get_color_map_square_hsv(self, mode='hsv'): global default_border global color_scale - hue, saturation, value = alg.no_nans(self.color.convert(mode)[:-1]) + hue, saturation, value = self.color.convert(mode).coords(nans=False) r_sat = saturation r_val = value @@ -248,7 +248,7 @@ def get_color_map_square(self, mode='hsl'): global default_border global color_scale - hue, saturation, lightness = alg.no_nans(self.color.convert(mode)[:-1]) + hue, saturation, lightness = self.color.convert(mode).coords(nans=False) r_sat = saturation r_lit = lightness diff --git a/color_helper.sublime-settings b/color_helper.sublime-settings index 48b6768..554df86 100755 --- a/color_helper.sublime-settings +++ b/color_helper.sublime-settings @@ -117,7 +117,7 @@ "gamut_space": "srgb", // Gamut mapping approach - // Supported methods are: `lch-chroma`, `oklch-chroma`, and `clip` (default). + // Supported methods are: `lch-chroma`, `oklch-chroma`, `oklch-raytrace`, and `clip` (default). // `lch-chroma` was the original default before this was configurable. "gamut_map": "clip", @@ -135,7 +135,6 @@ // "ColorHelper.lib.coloraide.spaces.acescc.ACEScc", // "ColorHelper.lib.coloraide.spaces.acescg.ACEScg", // "ColorHelper.lib.coloraide.spaces.acescct.ACEScct", - // "ColorHelper.lib.coloraide.spaces.cam16.CAM16", // "ColorHelper.lib.coloraide.spaces.cam16_jmh.CAM16JMh", // "ColorHelper.lib.coloraide.spaces.cam16_ucs.CAM16UCS", // "ColorHelper.lib.coloraide.spaces.cam16_ucs.CAM16SCD", @@ -149,7 +148,7 @@ // "ColorHelper.lib.coloraide.spaces.hunter_lab.HunterLab", // "ColorHelper.lib.coloraide.spaces.ictcp.ICtCp", // "ColorHelper.lib.coloraide.spaces.igtgpg.IgTgPg", - // "ColorHelper.lib.coloraide.spaces.itp.ITP", + // "ColorHelper.lib.coloraide.spaces.ipt.IPT", // "ColorHelper.lib.coloraide.spaces.jzazbz.Jzazbz", // "ColorHelper.lib.coloraide.spaces.jzczhz.JzCzhz", // "ColorHelper.lib.coloraide.spaces.lch99o.LCh99o", @@ -158,6 +157,7 @@ // "ColorHelper.lib.coloraide.spaces.rec2100_hlg.Rec2100HLG", // "ColorHelper.lib.coloraide.spaces.rec2100_pq.Rec2100PQ", // "ColorHelper.lib.coloraide.spaces.rlab.RLAB", + // "ColorHelper.lib.coloraide.spaces.ryb.RYB", // "ColorHelper.lib.coloraide.spaces.xyb.XYB", // "ColorHelper.lib.coloraide.spaces.xyy.xyY", "ColorHelper.lib.coloraide.spaces.hsluv.HSLuv", @@ -257,7 +257,8 @@ "output": [ {"space": "srgb", "format": {"hex": true}}, {"space": "srgb", "format": {"comma": true, "precision": 3}}, - {"space": "hsl", "format": {"comma": true, "precision": 3}} + {"space": "hsl", "format": {"comma": true, "precision": 3}}, + {"space": "hwb", "format": {"comma": true, "precision": 3}} ] }, "tmtheme": { diff --git a/custom/ahex.py b/custom/ahex.py index 6fc4741..eca3d7d 100644 --- a/custom/ahex.py +++ b/custom/ahex.py @@ -36,8 +36,7 @@ class ASRGB(sRGB): COLOR_FORMAT = False - @classmethod - def match(cls, string, start=0, fullmatch=True): + def match(self, string, start=0, fullmatch=True): """Match a CSS color string.""" m = MATCH.match(string, start) @@ -45,9 +44,8 @@ def match(cls, string, start=0, fullmatch=True): return split_channels(m.group(0)), m.end(0) return None - @classmethod def to_string( - cls, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs + self, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs ): """Convert to Hex format.""" diff --git a/custom/ass_abgr.py b/custom/ass_abgr.py index 20d4eb3..bb761ad 100644 --- a/custom/ass_abgr.py +++ b/custom/ass_abgr.py @@ -32,8 +32,7 @@ def split_channels(color: str): class AssABGR(sRGB): """ASS `ABGR` color space.""" - @classmethod - def match(cls, string: str, start: int = 0, fullmatch: bool = True): + def match(self, string: str, start: int = 0, fullmatch: bool = True): """Match a color string.""" m = MATCH.match(string, start) @@ -41,8 +40,7 @@ def match(cls, string: str, start: int = 0, fullmatch: bool = True): return split_channels(m.group("color")), m.end(0) return None - @classmethod - def to_string(cls, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs): + def to_string(self, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs): """Convert color to `&HAABBGGRR`.""" options = kwargs diff --git a/custom/hex_0x.py b/custom/hex_0x.py index 7afd4a9..2050510 100644 --- a/custom/hex_0x.py +++ b/custom/hex_0x.py @@ -1,4 +1,4 @@ -"""Custon color that looks for colors of format `#RRGGBBAA` as `#AARRGGBB`.""" +"""Custom color that looks for colors of format `#RRGGBBAA` as `#AARRGGBB`.""" from ..lib.coloraide.spaces.srgb.css import sRGB from ..lib.coloraide.css import parse, serialize import re @@ -10,8 +10,7 @@ class HexSRGB(sRGB): """SRGB that looks for alpha first in hex format.""" - @classmethod - def match(cls, string, start=0, fullmatch=True): + def match(self, string, start=0, fullmatch=True): """Match a CSS color string.""" m = MATCH.match(string, start) @@ -19,9 +18,8 @@ def match(cls, string, start=0, fullmatch=True): return parse.parse_hex(m.group(0).replace('0x', '#', 1)), m.end(0) return None - @classmethod def to_string( - cls, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs + self, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs ): """Convert to CSS.""" diff --git a/custom/st_colormod.py b/custom/st_colormod.py index 7a78b46..d0d2d7f 100644 --- a/custom/st_colormod.py +++ b/custom/st_colormod.py @@ -184,33 +184,35 @@ def handle_vars(string, variables, parents=None): class HWB(HWBORIG): """HWB class that allows commas.""" - @classmethod - def match(cls, string, start=0, fullmatch=True): + def match(self, string, start=0, fullmatch=True): """Match a CSS color string.""" m = HWB_MATCH.match(string, start) if m is not None and (not fullmatch or m.end(0) == len(string)): return parse.parse_channels( list(RE_CHAN_VALUE.findall(string[m.end(1) + 1:m.end(0) - 1])), - cls.CHANNELS, scaled=True + self.CHANNELS, scaled=True ), m.end(0) return None - @classmethod def to_string( - cls, + self, parent, *, alpha=None, precision=None, - percent: bool = True, + percent=None, fit=True, none=False, + color: bool = False, comma: bool = False, **kwargs ) -> str: """Convert to CSS.""" + if percent is None: + percent = False if color else True + return serialize.serialize_css( parent, func='hwb', diff --git a/custom/tmtheme.py b/custom/tmtheme.py index 68c9710..0f5ffcb 100644 --- a/custom/tmtheme.py +++ b/custom/tmtheme.py @@ -695,9 +695,8 @@ def name2hex(name): class SRGBX11(sRGB): """sRGB class.""" - @classmethod def to_string( - cls, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs + self, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs ): """Convert to CSS.""" @@ -727,8 +726,7 @@ def to_string( return value - @classmethod - def match(cls, string, start=0, fullmatch=True): + def match(self, string, start=0, fullmatch=True): """Match a CSS color string.""" m = MATCH.match(string, start) diff --git a/docs/src/markdown/settings/previews.md b/docs/src/markdown/settings/previews.md index d921233..fff8d1a 100644 --- a/docs/src/markdown/settings/previews.md +++ b/docs/src/markdown/settings/previews.md @@ -88,7 +88,7 @@ else in the code. ```js // Gamut mapping approach - // Supported methods are: `lch-chroma`, `oklch-chroma`, and `clip` (default). + // Supported methods are: `lch-chroma`, `oklch-chroma`, `oklch-raytrace`, and `clip` (default). // `lch-chroma` was the original default before this was configurable. "gamut_map": "clip", ``` diff --git a/lib/coloraide/__meta__.py b/lib/coloraide/__meta__.py index 25fa2bb..181307f 100644 --- a/lib/coloraide/__meta__.py +++ b/lib/coloraide/__meta__.py @@ -1,4 +1,5 @@ """Meta related things.""" +from __future__ import annotations from collections import namedtuple import re @@ -83,7 +84,7 @@ def __new__( cls, major: int, minor: int, micro: int, release: str = "final", pre: int = 0, post: int = 0, dev: int = 0 - ) -> "Version": + ) -> Version: """Validate version info.""" # Ensure all parts are positive integers. @@ -192,5 +193,5 @@ def parse_version(ver: str) -> Version: return Version(major, minor, micro, release, pre, post, dev) -__version_info__ = Version(2, 9, 1, "final", post=1) +__version_info__ = Version(3, 3, 1, "final") __version__ = __version_info__._get_canonical() diff --git a/lib/coloraide/algebra.py b/lib/coloraide/algebra.py index f94f23e..38eb7bf 100644 --- a/lib/coloraide/algebra.py +++ b/lib/coloraide/algebra.py @@ -22,40 +22,26 @@ used as long as the final results are converted to normal types. It is certainly possible that we could switch to using `numpy` in a major release in the future. """ -import sys +from __future__ import annotations import math import operator import functools import itertools as it from .deprecate import deprecated from .types import ( - ArrayLike, MatrixLike, VectorLike, Array, Matrix, - Vector, Shape, ShapeLike, DimHints, SupportsFloatOrInt, MathType + ArrayLike, MatrixLike, VectorLike, TensorLike, Array, Matrix, Tensor, Vector, VectorBool, MatrixBool, TensorBool, + MatrixInt, MathType, Shape, ShapeLike, DimHints, SupportsFloatOrInt ) -from typing import Optional, Callable, Sequence, List, Union, Iterator, Tuple, Any, Iterable, overload, Dict +from typing import Callable, Sequence, Iterator, Any, Iterable, overload -NaN = float('nan') -INF = float('inf') -nan = NaN -inf = INF -tau = 2 * math.pi +NaN = math.nan +INF = math.inf -PY38 = (3, 8) <= sys.version_info +# Keeping for backwards compatibility +prod = math.prod _all = all _any = any -if sys.version_info >= (3, 8): - # Keeping for backwards compatibility - prod = math.prod -else: - def prod(values: Iterable[SupportsFloatOrInt]) -> SupportsFloatOrInt: - """Get the product of a list of numbers.""" - - if not values: - return 1 - - return functools.reduce(operator.mul, values) - # Shortcut for math operations # Specify one of these in divide, multiply, dot, etc. # to bypass analyzing the shape to determine which path @@ -77,7 +63,7 @@ def prod(values: Iterable[SupportsFloatOrInt]) -> SupportsFloatOrInt: D1_D2 = (1, 2) D2_SC = (2, 0) D2_D1 = (2, 1) -DN_DM = (3, 3) +DN_DM = None # Vector used to create a special matrix used in natural splines M141 = [1, 4, 1] @@ -86,30 +72,12 @@ def prod(values: Iterable[SupportsFloatOrInt]) -> SupportsFloatOrInt: ################################ # General math ################################ -def _math_isclose(a, b, rel_tol=1e-9, abs_tol=0.0): - """Test if values are close.""" - - return abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol) - -@deprecated("Please use math.isnan or alg.isnan for a generic approach for vectors and matrices") -def is_nan(obj: float) -> bool: - """Check if "not a number".""" - - return math.isnan(obj) - - -@deprecated("This will be removed at a future time") -def no_nans(value: Union[VectorLike, Iterable[float]], default: float = 0.0) -> Vector: - """Ensure there are no `NaN` values in a sequence.""" - - return [(default if is_nan(x) else x) for x in value] +def order(x: float) -> int: + """Get the order of magnitude of a number.""" - -@deprecated("This will be removed at a future time") -def no_nan(value: float, default: float = 0.0) -> float: - """Convert list of numbers or single number to valid numbers.""" - - return default if is_nan(value) else value + if x == 0: + return 0 + return math.floor(math.log10(abs(x))) def round_half_up(n: float, scale: int = 0) -> float: @@ -119,32 +87,54 @@ def round_half_up(n: float, scale: int = 0) -> float: return math.floor(n * mult + 0.5) / mult -def round_to(f: float, p: int = 0) -> float: +def round_to(f: float, p: int = 0, half_up: bool = True) -> float: """Round to the specified precision using "half up" rounding.""" + _round = round_half_up if half_up else round # type: Callable[..., float] # type: ignore[assignment] + # Do no rounding, just return a float with full precision if p == -1: return float(f) # Integer rounding - elif p == 0: - return round_half_up(f) + if p == 0: + return _round(f) # Ignore infinity - elif math.isinf(f): + if math.isinf(f): return f # Round to the specified precision else: whole = int(f) digits = 0 if whole == 0 else int(math.log10(-whole if whole < 0 else whole)) + 1 - return round_half_up(whole if digits > p else f, p - digits) + return _round(whole if digits > p else f, p - digits) + + +def minmax(value: VectorLike | Iterable[float]) -> tuple[float, float]: + """Return the minimum and maximum value.""" + + mn = INF + mx = -INF + e = -1 + + for i in value: + e += 1 + if i > mx: + mx = i + if i < mn: + mn = i + + if e == -1: + raise ValueError("minmax() arg is an empty sequence") + + return mn, mx def clamp( value: SupportsFloatOrInt, - mn: Optional[SupportsFloatOrInt] = None, - mx: Optional[SupportsFloatOrInt] = None + mn: SupportsFloatOrInt | None = None, + mx: SupportsFloatOrInt | None = None ) -> SupportsFloatOrInt: """Clamp the value to the the given minimum and maximum.""" @@ -158,6 +148,14 @@ def clamp( return value +def zdiv(a: float, b: float) -> float: + """Protect against zero divide.""" + + if b == 0: + return 0.0 + return a / b + + def cbrt(n: float) -> float: """Calculate cube root.""" @@ -168,7 +166,7 @@ def nth_root(n: float, p: float) -> float: """Calculate nth root while handling negative numbers.""" if p == 0: # pragma: no cover - return inf + return math.inf if n == 0: # Can't do anything with zero @@ -177,13 +175,20 @@ def nth_root(n: float, p: float) -> float: return math.copysign(abs(n) ** (p ** -1), n) -def npow(base: float, exp: float) -> float: - """Perform `pow` with a negative number.""" +def spow(base: float, exp: float) -> float: + """Perform `pow` with signed number.""" return math.copysign(abs(base) ** exp, base) -def rect_to_polar(a: float, b: float) -> Tuple[float, float]: +@deprecated("'npow' has been renamed to 'spow' (signed power), please migrate to avoid future issues.") +def npow(base: float, exp: float) -> float: # pragma: no cover + """Signed power.""" + + return spow(base, exp) + + +def rect_to_polar(a: float, b: float) -> tuple[float, float]: """Take rectangular coordinates and make them polar.""" c = math.sqrt(a ** 2 + b ** 2) @@ -191,7 +196,7 @@ def rect_to_polar(a: float, b: float) -> Tuple[float, float]: return c, h -def polar_to_rect(c: float, h: float) -> Tuple[float, float]: +def polar_to_rect(c: float, h: float) -> tuple[float, float]: """Take rectangular coordinates and make them polar.""" a = c * math.cos(math.radians(h)) @@ -228,14 +233,14 @@ def lerp2d(vertices: Matrix, t: Vector) -> Vector: Vertices should be in column form [[x...], [y...]]. """ - return [bilerp(*(vertices[i] + t)) for i in range(2)] + return [bilerp(*vertices[i], *t) for i in range(2)] def ilerp2d( vertices: Matrix, point: Vector, *, - vertices_t: Optional[Matrix] = None, + vertices_t: Matrix | None = None, max_iter: int = 20, tol: float = 1e-14 ) -> Vector: @@ -319,14 +324,14 @@ def lerp3d( Vertices should be in column form [[x...], [y...], [z...]]. """ - return [trilerp(*(vertices[i] + t)) for i in range(3)] + return [trilerp(*vertices[i], *t) for i in range(3)] def ilerp3d( vertices: Matrix, point: Vector, *, - vertices_t: Optional[Matrix] = None, + vertices_t: Matrix | None = None, max_iter: int = 20, tol: float = 1e-14 ) -> Vector: @@ -445,7 +450,7 @@ def _matrix_141(n: int) -> Matrix: return inv(m) -def naturalize_bspline_controls(coordinates: List[Vector]) -> None: +def naturalize_bspline_controls(coordinates: list[Vector]) -> None: """ Given a set of B-spline control points in the Nth dimension, create new naturalized interpolation control points. @@ -555,12 +560,12 @@ def monotone(p0: float, p1: float, p2: float, p3: float, t: float) -> float: m2 = (s1 + s2) * 0.5 # Center segment should be horizontal as there is no increase/decrease between the two points - if _math_isclose(p1, p2): + if math.isclose(p1, p2): m1 = m2 = 0.0 else: # Gradient is zero if segment is horizontal or if the left hand secant differs in sign from current. - if _math_isclose(p0, p1) or (math.copysign(1.0, s0) != math.copysign(1.0, s1)): + if math.isclose(p0, p1) or (math.copysign(1.0, s0) != math.copysign(1.0, s1)): m1 = 0.0 # Ensure gradient magnitude is either 3 times the left or current secant (smaller being preferred). @@ -568,7 +573,7 @@ def monotone(p0: float, p1: float, p2: float, p3: float, t: float) -> float: m1 *= min(3.0 * s0 / m1, min(3.0 * s1 / m1, 1.0)) # Gradient is zero if segment is horizontal or if the right hand secant differs in sign from current. - if _math_isclose(p2, p3) or (math.copysign(1.0, s1) != math.copysign(1.0, s2)): + if math.isclose(p2, p3) or (math.copysign(1.0, s1) != math.copysign(1.0, s2)): m2 = 0.0 # Ensure gradient magnitude is either 3 times the current or right secant (smaller being preferred). @@ -596,7 +601,7 @@ def monotone(p0: float, p1: float, p2: float, p3: float, t: float) -> float: 'catrom': catrom, 'monotone': monotone, 'linear': lerp -} # type: Dict[str, Callable[..., float]] +} # type: dict[str, Callable[..., float]] class Interpolate: @@ -617,7 +622,7 @@ def __init__( self.callback = callback self.linear = linear - def steps(self, count: int) -> List[Vector]: + def steps(self, count: int) -> list[Vector]: """Generate steps.""" divisor = count - 1 @@ -653,7 +658,7 @@ def __call__(self, t: float) -> Vector: return coord -def interpolate(points: List[Vector], method: str = 'linear') -> Interpolate: +def interpolate(points: list[Vector], method: str = 'linear') -> Interpolate: """Generic interpolation method.""" points = points[:] @@ -680,7 +685,7 @@ def interpolate(points: List[Vector], method: str = 'linear') -> Interpolate: ################################ # Matrix/linear algebra math ################################ -def pretty(value: Union[Array, float], *, _depth: int = 0, _shape: Optional[Shape] = None) -> str: +def pretty(value: float | ArrayLike, *, _depth: int = 0, _shape: Shape | None = None) -> str: """Format the print output.""" if _shape is None: @@ -696,19 +701,19 @@ def pretty(value: Union[Array, float], *, _depth: int = 0, _shape: Optional[Shap return str(value) -def pprint(value: Union[Array, float]) -> None: +def pprint(value: float | ArrayLike) -> None: """Print the matrix or value.""" print(pretty(value)) -def all(a: Union[float, ArrayLike]) -> bool: # noqa: A001 +def all(a: float | ArrayLike) -> bool: # noqa: A001 """Return true if all elements are "true".""" return _all(flatiter(a)) -def any(a: Union[float, ArrayLike]) -> bool: # noqa: A001 +def any(a: float | ArrayLike) -> bool: # noqa: A001 """Return true if all elements are "true".""" return _any(flatiter(a)) @@ -717,28 +722,33 @@ def any(a: Union[float, ArrayLike]) -> bool: # noqa: A001 def vdot(a: VectorLike, b: VectorLike) -> float: """Dot two vectors.""" + l = len(a) + if l != len(b): + raise ValueError('Vectors of size {} and {} are not aligned'.format(l, len(b))) s = 0.0 - for x, y in it.zip_longest(a, b): - s += x * y + i = 0 + while i < l: + s += a[i] * b[i] + i += 1 return s -def vcross(v1: VectorLike, v2: VectorLike) -> Vector: # pragma: no cover +def vcross(v1: VectorLike, v2: VectorLike) -> Any: # pragma: no cover """ Cross two vectors. Takes vectors of either 2 or 3 dimensions. If 2 dimensions, will return the z component. To mix 2 and 3 vector components, please use `cross` instead which will pad 2 dimension - vectors if the other is of 3 dimensions. `cross` has more overhead, so use `cross` if + vectors if the other is of 3 dimensions. `cross` has more overhead, so use `vcross` if you don't need broadcasting of any kind. """ l1 = len(v1) if l1 != len(v2): - raise ValueError('Incompatible dimensions for cross product,') + raise ValueError('Incompatible dimensions of {} and {} for cross product'.format(l1, len(v2))) if l1 == 2: - return [v1[0] * v2[1] - v1[1] * v2[0]] + return v1[0] * v2[1] - v1[1] * v2[0] elif l1 == 3: return [ v1[1] * v2[2] - v1[2] * v2[1], @@ -759,6 +769,11 @@ def acopy(a: MatrixLike) -> Matrix: ... +@overload +def acopy(a: TensorLike) -> Tensor: + ... + + def acopy(a: ArrayLike) -> Array: """Array copy.""" @@ -775,6 +790,11 @@ def _cross_pad(a: MatrixLike, s: Shape) -> Matrix: ... +@overload +def _cross_pad(a: TensorLike, s: Shape) -> Tensor: + ... + + def _cross_pad(a: ArrayLike, s: Shape) -> Array: """Pad an array with 2-D vectors.""" @@ -802,22 +822,7 @@ def _cross_pad(a: ArrayLike, s: Shape) -> Array: return m -@overload -def cross(a: VectorLike, b: VectorLike) -> Vector: - ... - - -@overload -def cross(a: MatrixLike, b: Any) -> Matrix: - ... - - -@overload -def cross(a: Any, b: MatrixLike) -> Matrix: - ... - - -def cross(a: ArrayLike, b: ArrayLike) -> Array: +def cross(a: ArrayLike, b: ArrayLike) -> Any: """Vector cross product.""" # Determine shape of arrays @@ -839,29 +844,35 @@ def cross(a: ArrayLike, b: ArrayLike) -> Array: b = _cross_pad(b, shape_b) shape_b = shape_b[:-1] + (3,) - if dims_a == 1: - if dims_b == 1: - # Cross two vectors - return vcross(a, b) # type: ignore[arg-type] + # Cross two vectors + if dims_a == 1 and dims_b == 1: + return vcross(a, b) # type: ignore[arg-type] + + # Calculate cases of vector crossed either 2-D or N-D matrix and vice versa + if dims_a == 1 or dims_b == 1: + # Calculate target shape + mdim = max(dims_a, dims_b) + new_shape = list(_broadcast_shape([shape_a, shape_b], mdim)) + if mdim > 1 and new_shape[-1] == 2: + new_shape.pop(-1) + + if dims_a == 2: + # Cross a 2-D matrix and a vector + result = [vcross(r, b) for r in a] # type: ignore[arg-type] + elif dims_b == 2: # Cross a vector and a 2-D matrix - return [vcross(a, r) for r in b] # type: ignore[arg-type] + result = [vcross(a, r) for r in b] # type: ignore[arg-type] + + elif dims_a > 2: + # Cross an N-D matrix and a vector + result = [vcross(r, b) for r in _extract_rows(a, shape_a)] # type: ignore[arg-type] + else: # Cross a vector and an N-D matrix - return reshape( # type: ignore[return-value] - [vcross(a, r) for r in _extract_rows(b, shape_b)], # type: ignore[arg-type] - shape_b - ) - elif dims_a == 2: - if dims_b == 1: - # Cross a 2-D matrix and a vector - return [vcross(r, b) for r in a] # type: ignore[arg-type] - elif dims_b == 1: - # Cross an N-D matrix and a vector - return reshape( # type: ignore[return-value] - [vcross(r, b) for r in _extract_rows(a, shape_a)], # type: ignore[arg-type] - shape_a - ) + result = [vcross(a, r) for r in _extract_rows(b, shape_b)] # type: ignore[arg-type] + + return reshape(result, new_shape) # Cross an N-D and M-D matrix bcast = broadcast(a, b) @@ -879,7 +890,14 @@ def cross(a: ArrayLike, b: ArrayLike) -> Array: b2 = [] count = 0 count += 1 - return reshape(data, bcast.shape) # type: ignore[return-value] + + # Adjust shape for the way cross outputs data + new_shape = list(bcast.shape) + mdim = max(dims_a, dims_b) + if mdim > 1 and new_shape[-1] == 2: + new_shape.pop(-1) + + return reshape(data, new_shape) def _extract_rows(m: ArrayLike, s: ShapeLike, depth: int = 0) -> Iterator[Vector]: @@ -901,70 +919,103 @@ def _extract_cols(m: ArrayLike, s: ShapeLike, depth: int = 0) -> Iterator[Vector elif not depth: yield m # type: ignore[misc] else: - yield from [[x[r] for x in m] for r in range(len(m[0]))] # type: ignore[arg-type, index] + yield from [[x[r] for x in m] for r in range(len(m[0]))] # type: ignore[arg-type, index, misc] + + +@overload +def dot(a: float, b: float, *, dims: DimHints | None = ...) -> float: + ... + + +@overload +def dot(a: float, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: + ... @overload -def dot(a: float, b: float, *, dims: Optional[DimHints] = None) -> float: +def dot(a: VectorLike, b: float, *, dims: DimHints | None = ...) -> Vector: ... @overload -def dot(a: float, b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def dot(a: float, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def dot(a: VectorLike, b: float, *, dims: Optional[DimHints] = None) -> Vector: +def dot(a: MatrixLike, b: float, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def dot(a: float, b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def dot(a: float, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: ... @overload -def dot(a: MatrixLike, b: float, *, dims: Optional[DimHints] = None) -> Matrix: +def dot(a: TensorLike, b: float, *, dims: DimHints | None = ...) -> Tensor: ... @overload -def dot(a: VectorLike, b: VectorLike, *, dims: Optional[DimHints] = None) -> float: +def dot(a: VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> float: ... @overload -def dot(a: VectorLike, b: MatrixLike, *, dims: Optional[DimHints] = None) -> Vector: +def dot(a: VectorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def dot(a: MatrixLike, b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def dot(a: MatrixLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def dot(a: MatrixLike, b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def dot(a: VectorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def dot(a: TensorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def dot(a: MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def dot(a: MatrixLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: + ... + + +@overload +def dot(a: TensorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: + ... + + +@overload +def dot(a: TensorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: ... def dot( - a: Union[float, ArrayLike], - b: Union[float, ArrayLike], + a: float | ArrayLike, + b: float | ArrayLike, *, - dims: Optional[DimHints] = None -) -> Union[float, Array]: + dims: DimHints | None = None, +) -> float | Array: """ - Get dot product of simple numbers, vectors, and matrices. - - Matrices will be detected and the appropriate logic applied - unless `dims` is provided. `dims` should simply describe the - number of dimensions of `a` and `b`: (2, 1) for a 2D and 1D array. - Providing `dims` will sidestep analyzing the matrix for a more - performant operation. Anything dimensions above 2 will be treated - as an ND x MD scenario and the actual dimensions will be extracted - regardless due to necessity. + Perform dot product. + + Operations involving scalars will be the same as calling `multiply`. + + If you are doing matrix multiplication, equivalent to `@` in `numpy`, + then you want to use `matmul` instead. Operations on arrays of dimension 2 + or less will act the same as `matmul`. """ if dims is None or dims[0] > 2 or dims[1] > 2: @@ -974,52 +1025,162 @@ def dot( dims_b = len(shape_b) # Handle matrices of N-D and M-D size - if dims_a and dims_b and dims_a > 2 or dims_b > 2: + if dims_a and dims_b and (dims_a > 2 or dims_b > 2): if dims_a == 1: # Dot product of vector and a M-D matrix shape_c = shape_b[:-2] + shape_b[-1:] return reshape([vdot(a, col) for col in _extract_cols(b, shape_b)], shape_c) # type: ignore[arg-type] + elif dims_b == 1: + # Dot product of vector and a M-D matrix + shape_c = shape_a[:-1] + return reshape([vdot(row, b) for row in _extract_rows(a, shape_a)], shape_c) # type: ignore[arg-type] else: # Dot product of N-D and M-D matrices # Resultant size: `dot(xy, yz) = xz` or `dot(nxy, myz) = nxmz` - rows = list(_extract_rows(a, shape_a)) # type: ignore[arg-type] + cols = list(_extract_cols(b, shape_b)) # type: ignore[arg-type] + return reshape( + [ + [sum(multiply(row, col)) for col in cols] + for row in _extract_rows(a, shape_a) # type: ignore[arg-type] + ], + shape_a[:-1] + shape_b[:-2] + shape_b[-1:] + ) + else: + dims_a, dims_b = dims + + # Operations with scalars are the same as simply multiplying + if not dims_a or not dims_b: + return multiply(a, b, dims=(dims_a, dims_b)) + + # Dot is identical to matrix multiply when dimensions are less than or equal to 2, + return matmul(a, b, dims=(dims_a, dims_b)) # type: ignore[arg-type] + + +@overload +def matmul(a: VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> float: + ... + + +@overload +def matmul(a: VectorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Vector: + ... + + +@overload +def matmul(a: MatrixLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: + ... + + +@overload +def matmul(a: VectorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def matmul(a: TensorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def matmul(a: MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: + ... + + +@overload +def matmul(a: MatrixLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: + ... + + +@overload +def matmul(a: TensorLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Tensor | Matrix: + ... + + +@overload +def matmul(a: TensorLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: + ... + + +def matmul( + a: ArrayLike, + b: ArrayLike, + *, + dims: DimHints | None = None, +) -> float | Array: + """ + Perform matrix multiplication of two arrays. + + Similar behavior as dot product, but this is limited to non-scalar values only. Additionally, + the behavior of dimensions greater than 2 will be different. Stacks of matrices are broadcast + together as if the matrices were elements, respecting the signature `(n,k),(k,m)->(n,m)`. + This follows `numpy` behavior and is equivalent to the `@` operation. + """ + + if dims is None or dims[0] > 2 or dims[1] > 2: + shape_a = shape(a) + shape_b = shape(b) + dims_a = len(shape_a) + dims_b = len(shape_b) + + # Handle matrices of N-D and M-D size + if dims_a and dims_b and (dims_a > 2 or dims_b > 2): + if dims_a == 1: + # Matrix multiply of vector and a M-D matrix + shape_c = shape_b[:-2] + shape_b[-1:] + return reshape([vdot(a, col) for col in _extract_cols(b, shape_b)], shape_c) # type: ignore[arg-type] + elif dims_b == 1: + # Matrix multiply of vector and a M-D matrix + shape_c = shape_a[:-1] + return reshape([vdot(row, b) for row in _extract_rows(a, shape_a)], shape_c) # type: ignore[arg-type] + elif shape_a[-1] == shape_b[-2]: + # Stacks of matrices are broadcast together as if the matrices were elements, + # respecting the signature `(n,k),(k,m)->(n,m)`. + common = _broadcast_shape([shape_a[:-2], shape_b[:-2]], max(dims_a, dims_b) - 2) + shape_a = common + shape_a[-2:] + a = broadcast_to(a, shape_a) + shape_b = common + shape_b[-2:] + b = broadcast_to(b, shape_b) m2 = [ - [sum(multiply(row, col)) for col in _extract_cols(b, shape_b)] # type: ignore[arg-type] - for row in rows + matmul(a1, b1, dims=D2) + for a1, b1 in zip(_extract_rows(a, shape_a[:-1]), _extract_rows(b, shape_b[:-1])) ] - shape_c = shape_a[:-1] - if dims_b != 1: - shape_c += shape_b[:-2] + shape_b[-1:] - return reshape(m2, shape_c) + return reshape(m2, common + (shape_a[-2], shape_b[-1])) + raise ValueError( + 'Incompatible shapes in core dimensions (n?,k),(k,m?)->(n?,m?), {} != {}'.format( + shape_a[-1], + shape_b[-2] + ) + ) else: dims_a, dims_b = dims # Optimize to handle arrays <= 2-D if dims_a == 1: if dims_b == 1: - # Dot product of two vectors + # Matrix multiply of two vectors return vdot(a, b) # type: ignore[arg-type] elif dims_b == 2: - # Dot product of vector and a matrix - return [vdot(a, col) for col in it.zip_longest(*b)] # type: ignore[arg-type, misc] + # Matrix multiply of vector and a matrix + return [vdot(a, col) for col in it.zip_longest(*b)] # type: ignore[arg-type] elif dims_a == 2: if dims_b == 1: - # Dot product of matrix and a vector - return [vdot(row, b) for row in a] # type: ignore[arg-type, union-attr] + # Matrix multiply of matrix and a vector + return [vdot(row, b) for row in a] # type: ignore[arg-type] elif dims_b == 2: - # Dot product of two matrices + # Matrix multiply of two matrices + cols = list(it.zip_longest(*b)) return [ - [vdot(row, col) for col in it.zip_longest(*b)] for row in a # type: ignore[arg-type, misc, union-attr] + [vdot(row, col) for col in cols] for row in a # type: ignore[arg-type] ] - # Trying to dot a number with a vector or a matrix, so just multiply - return multiply(a, b, dims=(dims_a, dims_b)) + # Scalars are not allowed + raise ValueError('Inputs require at least 1 dimension, scalars are not allowed') -def _matrix_chain_order(shapes: Sequence[Shape]) -> List[List[int]]: +def _matrix_chain_order(shapes: Sequence[Shape]) -> MatrixInt: """ Calculate chain order. @@ -1038,13 +1199,13 @@ def _matrix_chain_order(shapes: Sequence[Shape]) -> List[List[int]]: n = len(shapes) m = full((n, n), 0) # type: Any - s = full((n, n), 0) # type: List[List[int]] # type: ignore[assignment] + s = full((n, n), 0) # type: MatrixInt # type: ignore[assignment] p = [a[0] for a in shapes] + [shapes[-1][1]] for d in range(1, n): for i in range(n - d): j = i + d - m[i][j] = inf + m[i][j] = math.inf for k in range(i, j): cost = m[i][k] + m[k + 1][j] + p[i] * p[k + 1] * p[j + 1] if cost < m[i][j]: @@ -1053,7 +1214,7 @@ def _matrix_chain_order(shapes: Sequence[Shape]) -> List[List[int]]: return s -def _multi_dot(arrays: Sequence[ArrayLike], indexes: List[List[int]], i: int, j: int) -> ArrayLike: +def _multi_dot(arrays: Sequence[ArrayLike], indexes: MatrixInt, i: int, j: int) -> ArrayLike: """Recursively dot the matrices in the array.""" if i != j: @@ -1149,7 +1310,7 @@ class _BroadcastTo: - The new shape. """ - def __init__(self, array: ArrayLike, old: Shape, new: Shape) -> None: + def __init__(self, array: ArrayLike | float, old: Shape, new: Shape) -> None: """Initialize.""" self._loop1 = 0 @@ -1157,7 +1318,7 @@ def __init__(self, array: ArrayLike, old: Shape, new: Shape) -> None: self._chunk_subindex = 0 self._chunk_max = 0 self._chunk_index = 0 - self._chunk = [] # type: List[float] + self._chunk = [] # type: Vector # Unravel the data as it will be quicker to slice the data in a flattened form # than iterating over the dimensions to replicate the data. @@ -1262,7 +1423,7 @@ class _SimpleBroadcast: def __init__( self, - arrays: Sequence[Union[ArrayLike, float]], + arrays: Sequence[ArrayLike | float], shapes: Sequence[Shape], new: ShapeLike ) -> None: @@ -1272,7 +1433,7 @@ def __init__( total = len(arrays) if total == 0: - a, b = None, None # type: Tuple[Any, Any] + a, b = None, None # type: tuple[Any, Any] elif total == 1: a, b = arrays[0], None else: @@ -1286,7 +1447,7 @@ def __init__( self.reset() - def vector_broadcast(self, a: VectorLike, b: VectorLike) -> Iterator[Tuple[float, ...]]: + def vector_broadcast(self, a: VectorLike, b: VectorLike) -> Iterator[tuple[float, ...]]: """Broadcast two vectors.""" # Broadcast the vector @@ -1299,10 +1460,10 @@ def vector_broadcast(self, a: VectorLike, b: VectorLike) -> Iterator[Tuple[float def broadcast( self, - a: Optional[Union[ArrayLike, float]], - b: Optional[Union[ArrayLike, float]], + a: ArrayLike | float | None, + b: ArrayLike | float | None, dims_a: int, dims_b: int - ) -> Iterator[Tuple[float, ...]]: + ) -> Iterator[tuple[float, ...]]: """Simple broadcast of a single array or two arrays with dimensions less than 2.""" # One of the common dimensions makes this result empty @@ -1322,8 +1483,19 @@ def broadcast( yield from self.vector_broadcast(a, b) # type: ignore[arg-type] elif dims_a == 2: # Broadcast two 2-D matrices - for ra, rb in it.zip_longest(a, b): # type: ignore[arg-type] - yield from self.vector_broadcast(ra, rb) # type: ignore[arg-type] + la = len(a) # type: ignore[arg-type] + lb = len(b) # type: ignore[arg-type] + if la == 1 and lb != 1: + ra = a[0] # type: ignore[index] + for rb in b: # type: ignore[union-attr] + yield from self.vector_broadcast(ra, rb) # type: ignore[arg-type] + elif lb == 1 and la != 1: + rb = b[0] # type: ignore[index] + for ra in a: # type: ignore[union-attr] + yield from self.vector_broadcast(ra, rb) # type: ignore[arg-type] + else: + for ra, rb in it.zip_longest(a, b): # type: ignore[arg-type] + yield from self.vector_broadcast(ra, rb) # type: ignore[arg-type] else: yield a, b # type: ignore[misc] @@ -1362,23 +1534,47 @@ def reset(self) -> None: self._iter = self.broadcast(self.a, self.b, self.dims_a, self.dims_b) - def __next__(self) -> Tuple[float, ...]: + def __next__(self) -> tuple[float, ...]: """Next.""" # Get the next chunk of data return next(self._iter) - def __iter__(self) -> Iterator[Tuple[float, ...]]: # pragma: no cover + def __iter__(self) -> Iterator[tuple[float, ...]]: # pragma: no cover """Iterate.""" # Setup and and return the iterator. return self +def _broadcast_shape(shapes: list[Shape], max_dims: int, stage1_shapes: list[Shape] | None = None) -> Shape: + """Find the common shape.""" + + # Adjust array shapes by padding out with '1's until matches max dimensions + if stage1_shapes is None: + stage1_shapes = [] + + for s in shapes: + dims = len(s) + stage1_shapes.append(((1,) * (max_dims - dims)) + s if dims < max_dims else s) + + # Determine a common shape, if possible + s2 = [] + for dim in zip(*stage1_shapes): + mx = 1 + for d in dim: + if d != 1 and (d != mx and mx != 1): + raise ValueError("Could not broadcast arrays as shapes are incompatible") + if d != 1: + mx = d + s2.append(mx) + return tuple(s2) + + class Broadcast: """Broadcast.""" - def __init__(self, *arrays: Union[ArrayLike, float]) -> None: + def __init__(self, *arrays: ArrayLike | float) -> None: """Broadcast.""" # Determine maximum dimensions @@ -1391,29 +1587,14 @@ def __init__(self, *arrays: Union[ArrayLike, float]) -> None: max_dims = dims shapes.append(s) - # Adjust array shapes by padding out with '1's until matches max dimensions - stage1_shapes = [] - for s in shapes: - dims = len(s) - stage1_shapes.append(((1,) * (max_dims - dims)) + s if dims < max_dims else s) - - # Determine a common shape, if possible - s2 = [] - for dim in zip(*stage1_shapes): - mx = 1 - for d in dim: - if d != 1 and (d != mx and mx != 1): - raise ValueError("Could not broadcast arrays as shapes are incompatible") - if d != 1: - mx = d - s2.append(mx) - common = tuple(s2) + stage1_shapes = [] # type: list[Shape] + common = _broadcast_shape(shapes, max_dims, stage1_shapes) # Create iterators to "broadcast to" total = len(arrays) self.simple = total < 2 or (total == 2 and len(common) <= 2) if self.simple: - self.iters = [_SimpleBroadcast(arrays, shapes, common)] # type: Union[List[_BroadcastTo], List[_SimpleBroadcast]] + self.iters = [_SimpleBroadcast(arrays, shapes, common)] # type: list[_BroadcastTo] | list[_SimpleBroadcast] else: self.iters = [_BroadcastTo(a, s1, common) for a, s1 in zip(arrays, stage1_shapes)] @@ -1437,26 +1618,26 @@ def reset(self) -> None: i.reset() self._init() - def __next__(self) -> Tuple[float, float]: + def __next__(self) -> tuple[float, ...]: """Next.""" # Get the next chunk of data - return next(self._iter) # type: ignore[return-value] + return next(self._iter) # type: ignore[arg-type] - def __iter__(self) -> 'Broadcast': + def __iter__(self) -> Broadcast: """Iterate.""" # Setup and and return the iterator. return self -def broadcast(*arrays: ArrayLike) -> Broadcast: +def broadcast(*arrays: ArrayLike | float) -> Broadcast: """Broadcast.""" return Broadcast(*arrays) -def broadcast_to(a: Union[ArrayLike, float], s: Union[int, ShapeLike]) -> Array: +def broadcast_to(a: ArrayLike | float, s: int | ShapeLike) -> Array: """Broadcast array to a shape.""" if not isinstance(s, Sequence): @@ -1501,8 +1682,8 @@ class vectorize: def __init__( self, pyfunc: Callable[..., Any], - doc: Optional[str] = None, - excluded: Optional[Sequence[Union[str, int]]] = None + doc: str | None = None, + excluded: Sequence[str | int] | None = None ) -> None: """Initialize.""" @@ -1556,24 +1737,72 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: return self.func(*inputs, **kwargs) -class vectorize2: +class vectorize1: """ A special version of vectorize that only broadcasts the first two inputs. This approach is faster than vectorize because it limits the inputs and allows us to skip a lot of the generalized code that can slow the things down. Additionally, we allow a `dims` keyword that allows you to specify the dimensions of the inputs - that can fast track a decision on how to process in the inputs. + that can fast track a decision on how to process in the inputs. The positional + argument is always vectorized and are expected to be numbers. + + For more flexibility, use `vectorize` which allows arbitrary vectorization of any and + all inputs at the cost of speed. + """ - If desired, a function that takes either one or two positional arguments is allowed, - no more no less. The second positional argument can be optional. The positional + def __init__(self, pyfunc: Callable[..., Any], doc: str | None = None): + """Initialize.""" + + self.func = pyfunc + + # Setup function name and docstring + self.__name__ = self.func.__name__ + self.__doc__ = self.func.__doc__ if doc is None else doc + + def __call__( + self, + a: ArrayLike | float, + dims: DimHints | None = None, + **kwargs: Any + ) -> Any: + """Call the vectorized function.""" + + if dims and 0 <= dims[0] <= 2: + dims_a = dims[0] + else: + dims_a = len(shape(a)) + + # Fast paths for scalar, vectors, and 2D matrices + # Scalar + if dims_a == 0: + return self.func(a, **kwargs) + # Vector + elif dims_a == 1: + return [self.func(i, **kwargs) for i in a] # type: ignore[union-attr] + # 2D matrix + elif dims_a == 2: + return [[self.func(c, **kwargs) for c in r] for r in a] # type: ignore[union-attr] + + # Unknown size or larger than 2D (slow) + return reshape([self.func(f, **kwargs) for f in flatiter(a)], shape(a)) + + +class vectorize2: + """ + A special version of vectorize that only broadcasts the first two inputs. + + This approach is faster than vectorize because it limits the inputs and allows us + to skip a lot of the generalized code that can slow the things down. Additionally, + we allow a `dims` keyword that allows you to specify the dimensions of the inputs + that can fast track a decision on how to process in the inputs. The positional arguments are always vectorized and are expected to be numbers. For more flexibility, use `vectorize` which allows arbitrary vectorization of any and all inputs at the cost of speed. """ - def __init__(self, pyfunc: Callable[..., Any], doc: Optional[str] = None): + def __init__(self, pyfunc: Callable[..., Any], doc: str | None = None): """Initialize.""" self.func = pyfunc @@ -1595,38 +1824,13 @@ def _vector_apply(self, a: VectorLike, b: VectorLike, **kwargs: Any) -> Vector: def __call__( self, - *args: Union[ArrayLike, float], - dims: Optional[DimHints] = None, + a: ArrayLike | float, + b: ArrayLike | float, + dims: DimHints | None = None, **kwargs: Any ) -> Any: """Call the vectorized function.""" - if len(args) == 1: - a, = args - if dims and 0 <= dims[0] <= 2: - dims_a = dims[0] - # Shape doesn't matter as we will utilize a fast path - shape_a = (0,) # type: Shape - else: - shape_a = shape(a) - dims_a = len(shape(a)) - - # Fast paths for scalar, vectors, and 2D matrices - # Scalar - if dims_a == 0: - return self.func(a, **kwargs) - # Vector - elif dims_a == 1: - return [self.func(i, **kwargs) for i in a] # type: ignore[union-attr] - # 2D matrix - elif dims_a == 2: - return [[self.func(c, **kwargs) for c in r] for r in a] # type: ignore[union-attr] - - # Unknown size or larger than 2D (slow) - return reshape([self.func(f, **kwargs) for f in flatiter(a)], shape_a) - - a, b = args - if not dims or dims[0] > 2 or dims[1] > 2: shape_a = shape(a) shape_b = shape(b) @@ -1661,9 +1865,18 @@ def __call__( return self._vector_apply(a, b, **kwargs) # type: ignore[arg-type] elif dims_a == 2: # Apply math to two 2-D matrices + la = len(a) # type: ignore[arg-type] + lb = len(b) # type: ignore[arg-type] + if la == 1 and lb != 1: + ra = a[0] # type: ignore[index] + return [self._vector_apply(ra, rb) for rb in b] # type: ignore[arg-type, union-attr] + elif lb == 1 and la != 1: + rb = b[0] # type: ignore[index] + return [self._vector_apply(ra, rb) for ra in a] # type: ignore[arg-type, union-attr] return [ self._vector_apply(ra, rb, **kwargs) for ra, rb in it.zip_longest(a, b) # type: ignore[arg-type] ] + # Apply math to two scalars return self.func(a, b, **kwargs) # Inputs containing a scalar on either side @@ -1694,26 +1907,26 @@ def linspace(start: float, stop: float) -> Vector: @overload -def linspace(start: VectorLike, stop: Union[VectorLike, float]) -> Matrix: +def linspace(start: VectorLike, stop: VectorLike | float) -> Matrix: ... @overload -def linspace(start: Union[VectorLike, float], stop: VectorLike) -> Matrix: +def linspace(start: VectorLike | float, stop: VectorLike) -> Matrix: ... @overload -def linspace(start: MatrixLike, stop: ArrayLike) -> Matrix: +def linspace(start: MatrixLike, stop: ArrayLike) -> Tensor: ... @overload -def linspace(start: ArrayLike, stop: MatrixLike) -> Matrix: +def linspace(start: ArrayLike, stop: MatrixLike) -> Tensor: ... -def linspace(start: Union[ArrayLike, float], stop: Union[ArrayLike, float], num: int = 50, endpoint: bool = True) -> Array: +def linspace(start: ArrayLike | float, stop: ArrayLike | float, num: int = 50, endpoint: bool = True) -> Array: """Create a series of points in a linear space.""" if num < 0: @@ -1778,22 +1991,27 @@ def linspace(start: Union[ArrayLike, float], stop: Union[ArrayLike, float], num: def _isclose(a: float, b: float, *, equal_nan: bool = False, **kwargs: Any) -> bool: """Check if values are close.""" - close = _math_isclose(a, b, **kwargs) + close = math.isclose(a, b, **kwargs) return (math.isnan(a) and math.isnan(b)) if not close and equal_nan else close @overload # type: ignore[no-overload-impl] -def isclose(a: float, b: float, *, dims: Optional[DimHints] = None, **kwargs: Any) -> bool: +def isclose(a: float, b: float, *, dims: DimHints | None = ..., **kwargs: Any) -> bool: + ... + + +@overload +def isclose(a: VectorLike, b: VectorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> VectorBool: ... @overload -def isclose(a: VectorLike, b: VectorLike, *, dims: Optional[DimHints] = None, **kwargs: Any) -> List[bool]: +def isclose(a: MatrixLike, b: MatrixLike, *, dims: DimHints | None = ..., **kwargs: Any) -> MatrixBool: ... @overload -def isclose(a: MatrixLike, b: MatrixLike, *, dims: Optional[DimHints] = None, **kwargs: Any) -> List[List[bool]]: +def isclose(a: TensorLike, b: TensorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> TensorBool: ... @@ -1801,21 +2019,26 @@ def isclose(a: MatrixLike, b: MatrixLike, *, dims: Optional[DimHints] = None, ** @overload # type: ignore[no-overload-impl] -def isnan(a: float, *, dims: Optional[DimHints] = None, **kwargs: Any) -> bool: +def isnan(a: float, *, dims: DimHints | None = ..., **kwargs: Any) -> bool: ... @overload -def isnan(a: VectorLike, *, dims: Optional[DimHints] = None, **kwargs: Any) -> List[bool]: +def isnan(a: VectorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> VectorBool: ... @overload -def isnan(a: MatrixLike, *, dims: Optional[DimHints] = None, **kwargs: Any) -> List[List[bool]]: +def isnan(a: MatrixLike, *, dims: DimHints | None = ..., **kwargs: Any) -> MatrixBool: ... -isnan = vectorize2(math.isnan) # type: ignore[assignment] +@overload +def isnan(a: TensorLike, *, dims: DimHints | None = ..., **kwargs: Any) -> TensorBool: + ... + + +isnan = vectorize1(math.isnan) # type: ignore[assignment] def allclose(a: MathType, b: MathType, **kwargs: Any) -> bool: @@ -1825,153 +2048,157 @@ def allclose(a: MathType, b: MathType, **kwargs: Any) -> bool: @overload # type: ignore[no-overload-impl] -def multiply(a: float, b: float, *, dims: Optional[DimHints] = None) -> float: +def multiply(a: float, b: float, *, dims: DimHints | None = ...) -> float: ... @overload -def multiply(a: Union[float, VectorLike], b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def multiply(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def multiply(a: VectorLike, b: Union[float, VectorLike], *, dims: Optional[DimHints] = None) -> Vector: +def multiply(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def multiply(a: MatrixLike, b: Union[float, ArrayLike], *, dims: Optional[DimHints] = None) -> Matrix: +def multiply(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def multiply(a: Union[ArrayLike, float], b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def multiply(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... -multiply = vectorize2(operator.mul, doc="Multiply two arrays or floats.") # type: ignore[assignment] - -@overload # type: ignore[no-overload-impl] -def divide(a: float, b: float, *, dims: Optional[DimHints] = None) -> float: +@overload +def multiply(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: ... @overload -def divide(a: Union[float, VectorLike], b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def multiply(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: ... -@overload -def divide(a: VectorLike, b: Union[float, VectorLike], *, dims: Optional[DimHints] = None) -> Vector: - ... +multiply = vectorize2(operator.mul, doc="Multiply two arrays or floats.") # type: ignore[assignment] -@overload -def divide(a: MatrixLike, b: Union[float, ArrayLike], *, dims: Optional[DimHints] = None) -> Matrix: +@overload # type: ignore[no-overload-impl] +def divide(a: float, b: float, *, dims: DimHints | None = ...) -> float: ... @overload -def divide(a: Union[ArrayLike, float], b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def divide(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: ... -divide = vectorize2(operator.truediv, doc="Divide two arrays or floats.") # type: ignore[assignment] - - -@overload # type: ignore[no-overload-impl] -def add(a: float, b: float, *, dims: Optional[DimHints] = None) -> float: +@overload +def divide(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def add(a: Union[float, VectorLike], b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def divide(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def add(a: VectorLike, b: Union[float, VectorLike], *, dims: Optional[DimHints] = None) -> Vector: +def divide(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def add(a: MatrixLike, b: Union[float, ArrayLike], *, dims: Optional[DimHints] = None) -> Matrix: +def divide(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: ... @overload -def add(a: Union[ArrayLike, float], b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def divide(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: ... -add = vectorize2(operator.add, doc="Add two arrays or floats.") # type: ignore[assignment] +divide = vectorize2(operator.truediv, doc="Divide two arrays or floats.") # type: ignore[assignment] @overload # type: ignore[no-overload-impl] -def subtract(a: float, b: float, *, dims: Optional[DimHints] = None) -> float: +def add(a: float, b: float, *, dims: DimHints | None = ...) -> float: ... @overload -def subtract(a: Union[float, VectorLike], b: VectorLike, *, dims: Optional[DimHints] = None) -> Vector: +def add(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def subtract(a: VectorLike, b: Union[float, VectorLike], *, dims: Optional[DimHints] = None) -> Vector: +def add(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def subtract(a: MatrixLike, b: Union[float, ArrayLike], *, dims: Optional[DimHints] = None) -> Matrix: +def add(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def subtract(a: Union[ArrayLike, float], b: MatrixLike, *, dims: Optional[DimHints] = None) -> Matrix: +def add(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... -subtract = vectorize2(operator.sub, doc="Subtract two arrays or floats.") # type: ignore[assignment] +@overload +def add(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: + ... @overload -def apply(fn: Callable[..., float], a: float, b: Optional[float] = None) -> float: +def add(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: + ... + + +add = vectorize2(operator.add, doc="Add two arrays or floats.") # type: ignore[assignment] + + +@overload # type: ignore[no-overload-impl] +def subtract(a: float, b: float, *, dims: DimHints | None = None) -> float: ... @overload -def apply(fn: Callable[..., float], a: Union[float, VectorLike], b: VectorLike) -> Vector: +def subtract(a: float | VectorLike, b: VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def apply(fn: Callable[..., float], a: VectorLike, b: Optional[Union[float, VectorLike]] = None) -> Vector: +def subtract(a: VectorLike, b: float | VectorLike, *, dims: DimHints | None = ...) -> Vector: ... @overload -def apply(fn: Callable[..., float], a: MatrixLike, b: Optional[Union[float, ArrayLike]] = None) -> Matrix: +def subtract(a: MatrixLike, b: float | VectorLike | MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... @overload -def apply(fn: Callable[..., float], a: Union[ArrayLike, float], b: MatrixLike) -> Matrix: +def subtract(a: float | VectorLike | MatrixLike, b: MatrixLike, *, dims: DimHints | None = ...) -> Matrix: ... -@deprecated("Please use vectorize2 (comparable in speed and features) or vectorize (more general purpose)") -def apply( - fn: Callable[..., float], - *args: Union[ArrayLike, float], - dims: Optional[DimHints] = None -) -> Union[float, Array]: - """Apply a given function over each element of the matrix.""" +@overload +def subtract(a: TensorLike, b: float | ArrayLike, *, dims: DimHints | None = ...) -> Tensor: + ... - return vectorize2(fn)(*args, dims=dims) # type: ignore[no-any-return] +@overload +def subtract(a: float | ArrayLike, b: TensorLike, *, dims: DimHints | None = ...) -> Tensor: + ... + +subtract = vectorize2(operator.sub, doc="Subtract two arrays or floats.") # type: ignore[assignment] -def full(array_shape: Union[int, ShapeLike], fill_value: Union[float, ArrayLike]) -> Array: + +def full(array_shape: int | ShapeLike, fill_value: float | ArrayLike) -> Array: """Create and fill a shape with the given values.""" # Ensure `shape` is a sequence of sizes @@ -1988,19 +2215,19 @@ def full(array_shape: Union[int, ShapeLike], fill_value: Union[float, ArrayLike] return reshape(fill_value, array_shape) # type: ignore[return-value] -def ones(array_shape: Union[int, ShapeLike]) -> Array: +def ones(array_shape: int | ShapeLike) -> Array: """Create and fill a shape with ones.""" return full(array_shape, 1.0) -def zeros(array_shape: Union[int, ShapeLike]) -> Array: +def zeros(array_shape: int | ShapeLike) -> Array: """Create and fill a shape with zeros.""" return full(array_shape, 0.0) -def ndindex(*s: ShapeLike) -> Iterator[Tuple[int, ...]]: +def ndindex(*s: ShapeLike) -> Iterator[tuple[int, ...]]: """Iterate dimensions.""" yield from it.product( @@ -2008,8 +2235,7 @@ def ndindex(*s: ShapeLike) -> Iterator[Tuple[int, ...]]: ) - -def flatiter(array: Union[float, ArrayLike]) -> Iterator[float]: +def flatiter(array: float | ArrayLike) -> Iterator[float]: """Traverse an array returning values.""" for indices in ndindex(shape(array)): @@ -2019,7 +2245,7 @@ def flatiter(array: Union[float, ArrayLike]) -> Iterator[float]: yield m -def ravel(array: Union[float, ArrayLike]) -> Vector: +def ravel(array: float | ArrayLike) -> Vector: """Return a flattened vector.""" return list(flatiter(array)) @@ -2038,7 +2264,7 @@ def _frange(start: float, stop: float, step: float) -> Iterator[float]: def arange( start: SupportsFloatOrInt, - stop: Optional[SupportsFloatOrInt] = None, + stop: SupportsFloatOrInt | None = None, step: SupportsFloatOrInt = 1 ) -> Vector: """ @@ -2069,11 +2295,16 @@ def transpose(array: VectorLike) -> Vector: @overload -def transpose(array: Matrix) -> Matrix: +def transpose(array: MatrixLike) -> Matrix: + ... + + +@overload +def transpose(array: TensorLike) -> Tensor: ... -def transpose(array: Union[ArrayLike, float]) -> Array: +def transpose(array: ArrayLike | float) -> Array | float: """ A simple transpose of a matrix. @@ -2081,7 +2312,7 @@ def transpose(array: Union[ArrayLike, float]) -> Array: we don't have a need for that, nor the desire to figure it out :). """ - s = tuple(reversed(shape(array))) + s = shape(array)[::-1] if not s: return array # type: ignore[return-value] @@ -2136,7 +2367,7 @@ def transpose(array: Union[ArrayLike, float]) -> Array: return m # type: ignore[no-any-return] -def reshape(array: ArrayLike, new_shape: Union[int, ShapeLike]) -> Union[float, Array]: +def reshape(array: ArrayLike | float, new_shape: int | ShapeLike) -> float | Array: """Change the shape of an array.""" # Ensure floats are arrays @@ -2152,9 +2383,8 @@ def reshape(array: ArrayLike, new_shape: Union[int, ShapeLike]) -> Union[float, v = ravel(array) if len(v) == 1: return v[0] - elif v: - # Kick out if the requested shape doesn't match the data - raise ValueError('Shape {} does not match the data total of {}'.format(new_shape, shape(array))) + # Kick out if the requested shape doesn't match the data + raise ValueError('Shape {} does not match the data total of {}'.format(new_shape, shape(array))) current_shape = shape(array) @@ -2196,7 +2426,7 @@ def reshape(array: ArrayLike, new_shape: Union[int, ShapeLike]) -> Union[float, return m # type: ignore[no-any-return] -def _shape(a: Any, s: Shape) -> Shape: +def _shape(a: ArrayLike | float, s: Shape) -> Shape: """ Get the shape of the array. @@ -2224,13 +2454,13 @@ def _shape(a: Any, s: Shape) -> Shape: return (size,) + first -def shape(a: Union[ArrayLike, float]) -> Shape: +def shape(a: ArrayLike | float) -> Shape: """Get the shape of a list.""" return _shape(a, ()) -def fill_diagonal(matrix: MatrixLike, val: Union[float, ArrayLike], wrap: bool = False) -> None: +def fill_diagonal(matrix: Matrix | Tensor, val: float | ArrayLike, wrap: bool = False) -> None: """Fill an N-D matrix diagonal.""" s = shape(matrix) @@ -2268,7 +2498,7 @@ def fill_diagonal(matrix: MatrixLike, val: Union[float, ArrayLike], wrap: bool = pos = pos + 1 if pos < dlen else 0 -def eye(n: int, m: Optional[int] = None, k: int = 0) -> Matrix: +def eye(n: int, m: int | None = None, k: int = 0) -> Matrix: """Create a diagonal of ones in a zero initialized matrix at the specified position.""" if m is None: @@ -2306,7 +2536,7 @@ def diag(array: MatrixLike, k: int = 0) -> Vector: ... -def diag(array: ArrayLike, k: int = 0) -> Array: +def diag(array: VectorLike | MatrixLike, k: int = 0) -> Array: """Create a diagonal matrix from a vector or return a vector of the diagonal of a matrix.""" s = shape(array) @@ -2343,11 +2573,11 @@ def diag(array: ArrayLike, k: int = 0) -> Array: def lu( - matrix: MatrixLike, + matrix: MatrixLike | TensorLike, *, permute_l: bool = False, p_indices: bool = False, - _shape: Optional[Shape] = None + _shape: Shape | None = None ) -> Any: """ Calculate `LU` decomposition. @@ -2400,12 +2630,12 @@ def lu( size = s[1] wide = True for _ in range(diff): - matrix.append([0.0] * size) # noqa: PERF401 + matrix.append([0.0] * size) # type: ignore[list-item] # noqa: PERF401 # Tall else: tall = True for row in matrix: - row.extend([0.0] * diff) + row.extend([0.0] * diff) # type: ignore[list-item] # Initialize the triangle matrices along with the permutation matrix. if p_indices or permute_l: @@ -2421,9 +2651,9 @@ def lu( # Partial pivoting: identify the row with the maximal value in the column j = i - maximum = abs(u[i][i]) + maximum = abs(u[i][i]) # type: ignore[var-annotated, arg-type] for k in range(i + 1, size): - a = abs(u[k][i]) + a = abs(u[k][i]) # type: ignore[var-annotated, arg-type] if a > maximum: j = k maximum = a @@ -2447,9 +2677,9 @@ def lu( # We have a pivot point, let's zero out everything above and below # the 'l' and 'u' diagonal respectively for j in range(i + 1, size): - scalar = u[j][i] / u[i][i] + scalar = u[j][i] / u[i][i] # type: ignore[operator] for k in range(i, size): - u[j][k] += -u[i][k] * scalar + u[j][k] += -u[i][k] * scalar # type: ignore[operator] l[j][k] += l[i][k] * scalar # Clean up the wide and tall matrices @@ -2533,7 +2763,17 @@ def solve(a: MatrixLike, b: MatrixLike) -> Matrix: ... -def solve(a: MatrixLike, b: ArrayLike) -> Array: +@overload +def solve(a: MatrixLike, b: TensorLike) -> Tensor: + ... + + +@overload +def solve(a: TensorLike, b: MatrixLike | TensorLike) -> Tensor | Matrix: + ... + + +def solve(a: MatrixLike | TensorLike, b: ArrayLike) -> Array: """ Solve the system of equations. @@ -2688,7 +2928,17 @@ def det(matrix: MatrixLike) -> Any: return [det(rows[r:r + step]) for r in range(0, len(rows), step)] +@overload def inv(matrix: MatrixLike) -> Matrix: + ... + + +@overload +def inv(matrix: TensorLike) -> Tensor: + ... + + +def inv(matrix: MatrixLike | TensorLike) -> Matrix | Tensor: """Invert the matrix using `LU` decomposition.""" # Ensure we have a square matrix @@ -2704,7 +2954,7 @@ def inv(matrix: MatrixLike) -> Matrix: rows = list(_extract_rows(matrix, s)) step = last[-2] invert = [inv(rows[r:r + step]) for r in range(0, len(rows), step)] - return reshape(invert, s) # type: ignore [arg-type, return-value] + return reshape(invert, s) # type: ignore[return-value] # Calculate the LU decomposition. size = s[0] @@ -2722,10 +2972,20 @@ def inv(matrix: MatrixLike) -> Matrix: return _back_sub_matrix(u, _forward_sub_matrix(l, p, s2), s2) -def vstack(arrays: Sequence[ArrayLike]) -> Array: +@overload +def vstack(arrays: Sequence[float | Vector | Matrix]) -> Matrix: + ... + + +@overload +def vstack(arrays: Sequence[Tensor]) -> Tensor: + ... + + +def vstack(arrays: Sequence[ArrayLike | float]) -> Matrix | Tensor: """Vertical stack.""" - m = [] # type: List[Array] + m = [] # type: list[Any] dims = 0 # Array tracking for verification @@ -2772,7 +3032,7 @@ def vstack(arrays: Sequence[ArrayLike]) -> Array: return m -def _hstack_extract(a: Union[ArrayLike, float], s: ShapeLike) -> Iterator[Array]: +def _hstack_extract(a: ArrayLike | float, s: ShapeLike) -> Iterator[Array]: """Extract data from the second axis.""" data = flatiter(a) @@ -2782,7 +3042,7 @@ def _hstack_extract(a: Union[ArrayLike, float], s: ShapeLike) -> Iterator[Array] yield [next(data) for _ in range(length)] -def hstack(arrays: Sequence[Union[ArrayLike, float]]) -> Array: +def hstack(arrays: Sequence[ArrayLike | float]) -> Array: """Horizontal stack.""" # Gather up shapes @@ -2850,7 +3110,7 @@ def hstack(arrays: Sequence[Union[ArrayLike, float]]) -> Array: return m1 # Iterate the arrays returning the content per second axis - m = [] # type: List[Any] + m = [] # type: list[Any] for data in it.zip_longest(*[_hstack_extract(a, s) for a, s in it.zip_longest(arrs, shapes) if s != (0,)]): for d in data: m.extend(d) @@ -2860,14 +3120,14 @@ def hstack(arrays: Sequence[Union[ArrayLike, float]]) -> Array: return reshape(m, new_shape) # type: ignore[return-value] -def outer(a: Union[float, ArrayLike], b: Union[float, ArrayLike]) -> Matrix: +def outer(a: float | ArrayLike, b: float | ArrayLike) -> Matrix: """Compute the outer product of two vectors (or flattened matrices).""" v2 = ravel(b) return [[x * y for y in v2] for x in flatiter(a)] -def inner(a: Union[float, ArrayLike], b: Union[float, ArrayLike]) -> Union[float, Array]: +def inner(a: float | ArrayLike, b: float | ArrayLike) -> float | Array: """Compute the inner product of two arrays.""" shape_a = shape(a) @@ -2889,7 +3149,7 @@ def inner(a: Union[float, ArrayLike], b: Union[float, ArrayLike]) -> Union[float if dims_a == 1: first = [a] # type: Any elif dims_a > 2: - first = list(_extract_rows(a, shape_a)) # type: ignore[arg-type] + first = _extract_rows(a, shape_a) # type: ignore[arg-type] else: first = a diff --git a/lib/coloraide/average.py b/lib/coloraide/average.py index 716c55f..36f0dc6 100644 --- a/lib/coloraide/average.py +++ b/lib/coloraide/average.py @@ -1,27 +1,27 @@ """Average colors together.""" +from __future__ import annotations import math -from . import algebra as alg from .types import ColorInput -from typing import Iterable, TYPE_CHECKING, Type +from typing import Iterable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from .color import Color def average( - create: Type['Color'], + create: type[Color], colors: Iterable[ColorInput], space: str, premultiplied: bool = True, powerless: bool = False -) -> 'Color': +) -> Color: """Average a list of colors together.""" obj = create(space, []) # Get channel information cs = obj.CS_MAP[space] - hue_index = cs.hue_index() if hasattr(cs, 'hue_index') else -1 + hue_index = cs.hue_index() if cs.is_polar() else -1 # type: ignore[attr-defined] channels = cs.channels chan_count = len(channels) alpha_index = chan_count - 1 @@ -36,7 +36,7 @@ def average( obj.update(c) # If cylindrical color is achromatic, ensure hue is undefined if powerless and hue_index >= 0 and not math.isnan(obj[hue_index]) and obj.is_achromatic(): - obj[hue_index] = alg.nan + obj[hue_index] = math.nan coords = obj[:] alpha = coords[-1] if math.isnan(alpha): @@ -59,16 +59,17 @@ def average( # Get the mean alpha = sums[-1] alpha_t = totals[-1] - sums[-1] = alg.nan if not alpha_t else alpha / alpha_t + sums[-1] = math.nan if not alpha_t else alpha / alpha_t alpha = sums[-1] if math.isnan(alpha) or alpha in (0.0, 1.0): alpha = 1.0 for i in range(chan_count - 1): total = totals[i] if not total: - sums[i] = alg.nan + sums[i] = math.nan elif i == hue_index: - sums[i] = math.degrees(math.atan2(sin / total, cos / total)) + avg_theta = math.degrees(math.atan2(sin / total, cos / total)) + sums[i] = (avg_theta + 360) if avg_theta < 0 else avg_theta else: sums[i] /= total * alpha if premultiplied else total diff --git a/lib/coloraide/cat.py b/lib/coloraide/cat.py index 1b7b907..3dee43a 100644 --- a/lib/coloraide/cat.py +++ b/lib/coloraide/cat.py @@ -1,10 +1,10 @@ """Chromatic adaptation transforms.""" +from __future__ import annotations from . import util from abc import ABCMeta, abstractmethod from . import algebra as alg import functools from .types import Matrix, VectorLike, Vector, Plugin -from typing import Any, Type, Tuple # noqa: F401 # From CIE 2004 Colorimetry T.3 and T.8 # B from https://en.wikipedia.org/wiki/Standard_illuminant#White_point @@ -40,10 +40,10 @@ def calc_adaptation_matrices( - w1: Tuple[float, float], - w2: Tuple[float, float], + w1: tuple[float, float], + w2: tuple[float, float], m: Matrix, -) -> Tuple[Matrix, Matrix]: +) -> tuple[Matrix, Matrix]: """ Get the von Kries based adaptation matrix based on the method and illuminants. @@ -54,12 +54,14 @@ def calc_adaptation_matrices( Granted, we are currently, capped at 20 in the cache, but the average user isn't going to be swapping between over 20 methods and white points in a short period of time. We could always increase the cache if necessary. + + http://www.brucelindbloom.com/index.html?Math.html """ - first = alg.dot(m, util.xy_to_xyz(w1), dims=alg.D2_D1) - second = alg.dot(m, util.xy_to_xyz(w2), dims=alg.D2_D1) - m2 = alg.diag(alg.divide(first, second, dims=alg.D1)) - adapt = alg.dot(alg.solve(m, m2), m) + src = alg.matmul(m, util.xy_to_xyz(w1), dims=alg.D2_D1) + dest = alg.matmul(m, util.xy_to_xyz(w2), dims=alg.D2_D1) + m2 = alg.diag(alg.divide(dest, src, dims=alg.D1)) + adapt = alg.matmul(alg.solve(m, m2), m, dims=alg.D2) return adapt, alg.inv(adapt) @@ -70,7 +72,7 @@ class CAT(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def adapt(self, w1: Tuple[float, float], w2: Tuple[float, float], xyz: VectorLike) -> Vector: + def adapt(self, w1: tuple[float, float], w2: tuple[float, float], xyz: VectorLike) -> Vector: """Adapt a given XYZ color using the provided white points.""" @@ -93,16 +95,23 @@ class VonKries(CAT): @classmethod @functools.lru_cache(maxsize=20) def get_adaptation_matrices( - cls: Type['VonKries'], - w1: Tuple[float, float], - w2: Tuple[float, float] - ) -> Tuple[Matrix, Matrix]: + cls: type[VonKries], + w1: tuple[float, float], + w2: tuple[float, float] + ) -> tuple[Matrix, Matrix]: """Get the adaptation matrices.""" return calc_adaptation_matrices(w1, w2, cls.MATRIX) - def adapt(self, w1: Tuple[float, float], w2: Tuple[float, float], xyz: VectorLike) -> Vector: - """Adapt a given XYZ color using the provided white points.""" + def adapt(self, w1: tuple[float, float], w2: tuple[float, float], xyz: VectorLike) -> Vector: + """ + Adapt a given XYZ color using the provided white points. + + Since we calculate and cache both the forward and inverse matrices, ensure the + calculation between two white points, regardless of which is source, are evaluated + the same. Once the matrices are retrieved, Just make sure we use the correct one + based on which white point is the source. + """ # We are already using the correct white point if w1 == w2: @@ -110,7 +119,7 @@ def adapt(self, w1: Tuple[float, float], w2: Tuple[float, float], xyz: VectorLik a, b = sorted([w1, w2]) m, mi = self.get_adaptation_matrices(a, b) - return alg.dot(mi if a != w2 else m, xyz, dims=alg.D2_D1) + return alg.matmul(mi if a != w1 else m, xyz, dims=alg.D2_D1) class Bradford(VonKries): diff --git a/lib/coloraide/channels.py b/lib/coloraide/channels.py index a8053c7..d21929a 100644 --- a/lib/coloraide/channels.py +++ b/lib/coloraide/channels.py @@ -1,5 +1,5 @@ """Channels.""" -from typing import Tuple, Optional +from __future__ import annotations FLG_ANGLE = 1 FLG_PERCENT = 2 @@ -10,14 +10,14 @@ class Channel(str): """Channel.""" - # low: float - # high: float - # span: float - # offset: float - # bound: bool - # flags: int - # limit: Tuple[Optional[float], Optional[float]] - # nans: float + low: float + high: float + span: float + offset: float + bound: bool + flags: int + limit: tuple[float | None, float | None] + nans: float def __new__( cls, @@ -27,7 +27,7 @@ def __new__( mirror_range: bool = False, bound: bool = False, flags: int = 0, - limit: Tuple[Optional[float], Optional[float]] = (None, None), + limit: tuple[float | None, float | None] = (None, None), nans: float = 0.0 ) -> 'Channel': """Initialize.""" diff --git a/lib/coloraide/color.py b/lib/coloraide/color.py index 54d4957..d319aed 100644 --- a/lib/coloraide/color.py +++ b/lib/coloraide/color.py @@ -1,4 +1,5 @@ """Colors.""" +from __future__ import annotations import abc import functools import random @@ -41,6 +42,12 @@ from .spaces.xyz_d50 import XYZD50 from .spaces.oklab.css import Oklab from .spaces.oklch.css import OkLCh +from .spaces.rec2100_pq import Rec2100PQ +from .spaces.rec2100_hlg import Rec2100HLG +from .spaces.rec2100_linear import Rec2100Linear +from .spaces.jzazbz import Jzazbz +from .spaces.jzczhz import JzCzhz +from .spaces.ictcp import ICtCp from .distance import DeltaE from .distance.delta_e_76 import DE76 from .distance.delta_e_94 import DE94 @@ -48,17 +55,23 @@ from .distance.delta_e_2000 import DE2000 from .distance.delta_e_hyab import DEHyAB from .distance.delta_e_ok import DEOK +from .distance.delta_e_itp import DEITP +from .distance.delta_e_z import DEZ from .contrast import ColorContrast from .contrast.wcag21 import WCAG21Contrast from .gamut import Fit from .gamut.fit_lch_chroma import LChChroma from .gamut.fit_oklch_chroma import OkLChChroma +from .gamut.fit_oklch_raytrace import OkLChRayTrace +from .gamut.fit_lch_raytrace import LChRayTrace +from .gamut.fit_raytrace import RayTrace from .cat import CAT, Bradford from .filters import Filter from .filters.w3c_filter_effects import Sepia, Brightness, Contrast, Saturate, Opacity, HueRotate, Grayscale, Invert from .filters.cvd import Protan, Deutan, Tritan from .interpolate import Interpolator, Interpolate from .interpolate.linear import Linear +from .interpolate.css_linear import CSSLinear from .interpolate.continuous import Continuous from .interpolate.bspline import BSpline from .interpolate.bspline_natural import NaturalBSpline @@ -67,16 +80,17 @@ from .temperature.ohno_2013 import Ohno2013 from .temperature.robertson_1968 import Robertson1968 from .types import Plugin -from typing import overload, Union, Sequence, Iterable, Dict, List, Optional, Any, Callable, Tuple, Type, Mapping +from typing import overload, Sequence, Iterable, Any, Callable, Mapping SUPPORTED_CHROMATICITY_SPACES = {'xyz', 'uv-1960', 'uv-1976', 'xy-1931'} + class ColorMatch: """Color match object.""" __slots__ = ('color', 'start', 'end') - def __init__(self, color: 'Color', start: int, end: int) -> None: + def __init__(self, color: Color, start: int, end: int) -> None: """Initialize.""" self.color = color @@ -94,29 +108,29 @@ def __str__(self) -> str: # pragma: no cover class ColorMeta(abc.ABCMeta): """Ensure on subclass that the subclass has new instances of mappings.""" - def __init__(cls, name: str, bases: Tuple[object, ...], clsdict: Dict[str, Any]) -> None: + def __init__(cls, name: str, bases: tuple[object, ...], clsdict: dict[str, Any]) -> None: """Copy mappings on subclass.""" # Ensure subclassed Color objects do not use the same plugin mappings if len(cls.mro()) > 2: - cls.CS_MAP = cls.CS_MAP.copy() # type: Dict[str, Space] - cls.DE_MAP = cls.DE_MAP.copy() # type: Dict[str, DeltaE] - cls.FIT_MAP = cls.FIT_MAP.copy() # type: Dict[str, Fit] - cls.CAT_MAP = cls.CAT_MAP.copy() # type: Dict[str, CAT] - cls.FILTER_MAP = cls.FILTER_MAP.copy() # type: Dict[str, Filter] - cls.CONTRAST_MAP = cls.CONTRAST_MAP.copy() # type: Dict[str, ColorContrast] - cls.INTERPOLATE_MAP = cls.INTERPOLATE_MAP.copy() # type: Dict[str, Interpolate] - cls.CCT_MAP = cls.CCT_MAP.copy() # type: Dict[str, CCT] + cls.CS_MAP = cls.CS_MAP.copy() # type: dict[str, Space] + cls.DE_MAP = cls.DE_MAP.copy() # type: dict[str, DeltaE] + cls.FIT_MAP = cls.FIT_MAP.copy() # type: dict[str, Fit] + cls.CAT_MAP = cls.CAT_MAP.copy() # type: dict[str, CAT] + cls.FILTER_MAP = cls.FILTER_MAP.copy() # type: dict[str, Filter] + cls.CONTRAST_MAP = cls.CONTRAST_MAP.copy() # type: dict[str, ColorContrast] + cls.INTERPOLATE_MAP = cls.INTERPOLATE_MAP.copy() # type: dict[str, Interpolate] + cls.CCT_MAP = cls.CCT_MAP.copy() # type: dict[str, CCT] # Ensure each derived class tracks its own conversion paths for color spaces # relative to the installed color space plugins. @classmethod # type: ignore[misc] @functools.lru_cache(maxsize=256) def _get_convert_chain( - cls: Type['Color'], - space: 'Space', + cls: type[Color], + space: Space, target: str - ) -> List[Tuple['Space', 'Space', int, bool]]: + ) -> list[tuple[Space, Space, int, bool]]: """Resolve a conversion chain, cache it for speed.""" return convert.get_convert_chain(cls, space, target) @@ -127,17 +141,18 @@ def _get_convert_chain( class Color(metaclass=ColorMeta): """Color class object which provides access and manipulation of color spaces.""" - CS_MAP = {} # type: Dict[str, Space] - DE_MAP = {} # type: Dict[str, DeltaE] - FIT_MAP = {} # type: Dict[str, Fit] - CAT_MAP = {} # type: Dict[str, CAT] - CONTRAST_MAP = {} # type: Dict[str, ColorContrast] - FILTER_MAP = {} # type: Dict[str, Filter] - INTERPOLATE_MAP = {} # type: Dict[str, Interpolate] - CCT_MAP = {} # type: Dict[str, CCT] + CS_MAP = {} # type: dict[str, Space] + DE_MAP = {} # type: dict[str, DeltaE] + FIT_MAP = {} # type: dict[str, Fit] + CAT_MAP = {} # type: dict[str, CAT] + CONTRAST_MAP = {} # type: dict[str, ColorContrast] + FILTER_MAP = {} # type: dict[str, Filter] + INTERPOLATE_MAP = {} # type: dict[str, Interpolate] + CCT_MAP = {} # type: dict[str, CCT] PRECISION = util.DEF_PREC FIT = util.DEF_FIT INTERPOLATE = util.DEF_INTERPOLATE + INTERPOLATOR = util.DEF_INTERPOLATOR DELTA_E = util.DEF_DELTA_E HARMONY = util.DEF_HARMONY AVERAGE = util.DEF_AVERAGE @@ -160,7 +175,7 @@ class Color(metaclass=ColorMeta): def __init__( self, color: ColorInput, - data: Optional[VectorLike] = None, + data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, **kwargs: Any ) -> None: @@ -174,27 +189,27 @@ def __len__(self) -> int: return len(self._space.CHANNELS) + 1 @overload - def __getitem__(self, i: Union[str, int]) -> float: # noqa: D105 + def __getitem__(self, i: str | int) -> float: ... @overload - def __getitem__(self, i: slice) -> Vector: # noqa: D105 + def __getitem__(self, i: slice) -> Vector: ... - def __getitem__(self, i: Union[str, int, slice]) -> Union[float, Vector]: + def __getitem__(self, i: str | int | slice) -> float | Vector: """Get channels.""" return self._coords[self._space.get_channel_index(i)] if isinstance(i, str) else self._coords[i] @overload - def __setitem__(self, i: Union[str, int], v: float) -> None: # noqa: D105 + def __setitem__(self, i: str | int, v: float) -> None: ... @overload - def __setitem__(self, i: slice, v: Vector) -> None: # noqa: D105 + def __setitem__(self, i: slice, v: Vector) -> None: ... - def __setitem__(self, i: Union[str, int, slice], v: Union[float, Vector]) -> None: + def __setitem__(self, i: str | int | slice, v: float | Vector) -> None: """Set channels.""" space = self._space @@ -218,10 +233,10 @@ def __eq__(self, other: Any) -> bool: def _parse( cls, color: ColorInput, - data: Optional[VectorLike] = None, + data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, **kwargs: Any - ) -> Tuple[Space, List[float]]: + ) -> tuple[Space, Vector]: """Parse the color.""" # Parse a color string or color space name and coordinates @@ -236,7 +251,7 @@ def _parse( num_channels = len(space_class.CHANNELS) num_data = len(data) if num_data < num_channels: - data = list(data) + [alg.nan] * (num_channels - num_data) + data = list(data) + [math.nan] * (num_channels - num_data) coords = [alg.clamp(float(v), *c.limit) for c, v in zipl(space_class.CHANNELS, data)] coords.append(alg.clamp(float(alpha), *space_class.channels[-1].limit)) obj = space_class, coords @@ -272,7 +287,7 @@ def _match( string: str, start: int = 0, fullmatch: bool = False - ) -> Optional[Tuple['Space', Vector, float, int, int]]: + ) -> tuple[Space, Vector, float, int, int] | None: """ Match a color in a buffer and return a color object. @@ -301,7 +316,7 @@ def match( string: str, start: int = 0, fullmatch: bool = False - ) -> Optional[ColorMatch]: + ) -> ColorMatch | None: """Match color.""" m = cls._match(string, start, fullmatch) @@ -324,7 +339,7 @@ def _is_color(cls, obj: Any) -> bool: @classmethod def register( cls, - plugin: Union[Plugin, Sequence[Plugin]], + plugin: Plugin | Sequence[Plugin], *, overwrite: bool = False, silent: bool = False @@ -382,7 +397,7 @@ def register( cls._get_convert_chain.cache_clear() @classmethod - def deregister(cls, plugin: Union[str, Sequence[str]], *, silent: bool = False) -> None: + def deregister(cls, plugin: str | Sequence[str], *, silent: bool = False) -> None: """Deregister a plugin by name of specified plugin type.""" reset_convert_cache = False @@ -390,7 +405,7 @@ def deregister(cls, plugin: Union[str, Sequence[str]], *, silent: bool = False) if isinstance(plugin, str): plugin = [plugin] - mapping = None # type: Optional[Dict[str, Any]] + mapping = None # type: dict[str, Any] | None for p in plugin: if p == '*': cls.CS_MAP.clear() @@ -447,7 +462,7 @@ def deregister(cls, plugin: Union[str, Sequence[str]], *, silent: bool = False) cls._get_convert_chain.cache_clear() @classmethod - def random(cls, space: str, *, limits: Optional[Sequence[Optional[Sequence[float]]]] = None) -> 'Color': + def random(cls, space: str, *, limits: Sequence[Sequence[float] | None] | None = None) -> Color: """Get a random color.""" # Get the color space and number of channels @@ -473,7 +488,7 @@ def random(cls, space: str, *, limits: Optional[Sequence[Optional[Sequence[float # Create the color obj = cls(space, coords) - if hasattr(obj._space, 'hue_index'): + if obj._space.is_polar(): obj.normalize() return obj @@ -485,10 +500,10 @@ def blackbody( duv: float = 0.0, *, scale: bool = True, - scale_space: Optional[str] = None, - method: Optional[str] = None, + scale_space: str | None = None, + method: str | None = None, **kwargs: Any - ) -> 'Color': + ) -> Color: """ Get a color along the black body curve. @@ -506,7 +521,7 @@ def blackbody( color = cct.from_cct(cls, space, temp, duv, scale, scale_space, **kwargs) return color - def cct(self, *, method: Optional[str] = None, **kwargs: Any) -> Vector: + def cct(self, *, method: str | None = None, **kwargs: Any) -> Vector: """Get color temperature.""" cct = temperature.cct(method, self) @@ -517,13 +532,13 @@ def to_dict(self, *, nans: bool = True) -> Mapping[str, Any]: return {'space': self.space(), 'coords': self.coords(nans=nans), 'alpha': self.alpha(nans=nans)} - def normalize(self, *, nans: bool = True) -> 'Color': + def normalize(self, *, nans: bool = True) -> Color: """Normalize the color.""" - self[:-1] = self.coords(nans=False) - if nans and hasattr(self._space, 'hue_index') and self.is_achromatic(): - i = self._space.hue_index() - self[i] = alg.nan + self[:-1] = self._space.normalize(self.coords(nans=False)) + if nans and self._space.is_polar() and self.is_achromatic(): + i = self._space.hue_index() # type: ignore[attr-defined] + self[i] = math.nan alpha = self[-1] self[-1] = 0.0 if math.isnan(alpha) else alpha return self @@ -533,7 +548,7 @@ def is_nan(self, name: str) -> bool: # pragma: no cover return math.isnan(self.get(name)) - def _handle_color_input(self, color: ColorInput) -> 'Color': + def _handle_color_input(self, color: ColorInput) -> Color: """Handle color input.""" if isinstance(color, (str, Mapping)): @@ -551,15 +566,15 @@ def space(self) -> str: def new( self, color: ColorInput, - data: Optional[VectorLike] = None, + data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, **kwargs: Any - ) -> 'Color': + ) -> Color: """Create new color object.""" return type(self)(color, data, alpha, **kwargs) - def clone(self) -> 'Color': + def clone(self) -> Color: """Clone.""" return self.new(self.space(), self[:-1], self[-1]) @@ -568,10 +583,10 @@ def convert( self, space: str, *, - fit: Union[bool, str] = False, + fit: bool | str = False, in_place: bool = False, norm: bool = True - ) -> 'Color': + ) -> Color: """Convert to color space.""" # Convert the color and then fit it. @@ -579,7 +594,7 @@ def convert( method = None if not isinstance(fit, str) else fit if not self.in_gamut(space, tolerance=0.0): converted = self.convert(space, in_place=in_place, norm=norm) - return converted.fit(space, method=method) + return converted.fit(method=method) # Nothing to do, just return the color with no alterations. if space == self.space(): @@ -592,8 +607,8 @@ def convert( this._coords[:-1] = coords # Normalize achromatic colors, but skip if we internally don't need this. - if norm and hasattr(this._space, 'hue_index') and this.is_achromatic(): - this[this._space.hue_index()] = alg.nan + if norm and this._space.is_polar() and this.is_achromatic(): + this[this._space.hue_index()] = math.nan # type: ignore[attr-defined] return this @@ -609,10 +624,10 @@ def is_achromatic(self) -> bool: def mutate( self, color: ColorInput, - data: Optional[VectorLike] = None, + data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, **kwargs: Any - ) -> 'Color': + ) -> Color: """Mutate the current color to a new color.""" self._space, self._coords = self._parse(color, data=data, alpha=alpha, **kwargs) @@ -621,12 +636,12 @@ def mutate( def update( self, color: ColorInput, - data: Optional[VectorLike] = None, + data: VectorLike | None = None, alpha: float = util.DEF_ALPHA, *, norm: bool = True, **kwargs: Any - ) -> 'Color': + ) -> Color: """Update the existing color space with the provided color.""" space = self.space() @@ -635,7 +650,7 @@ def update( self.convert(space, in_place=True, norm=norm) return self - def _hotswap(self, color: 'Color') -> 'Color': + def _hotswap(self, color: Color) -> Color: """ Hot swap a color object. @@ -667,12 +682,12 @@ def white(self, cspace: str = 'xyz') -> Vector: value = self.convert_chromaticity('xy-1931', cspace, self._space.WHITE) return value if cspace == 'xyz' else value[:-1] - def uv(self, mode: str = '1976', *, white: Optional[VectorLike] = None) -> Vector: + def uv(self, mode: str = '1976', *, white: VectorLike | None = None) -> Vector: """Convert to `xy`.""" return self.split_chromaticity('uv-' + mode)[:-1] - def xy(self, *, white: Optional[VectorLike] = None) -> Vector: + def xy(self, *, white: VectorLike | None = None) -> Vector: """Convert to `xy`.""" return self.split_chromaticity('xy-1931')[:-1] @@ -681,7 +696,7 @@ def split_chromaticity( self, cspace: str = 'uv-1976', *, - white: Optional[VectorLike] = None + white: VectorLike | None = None ) -> Vector: """ Split a color into chromaticity and luminance coordinates. @@ -719,9 +734,9 @@ def chromaticity( cspace: str = 'uv-1976', *, scale: bool = False, - scale_space: Optional[str] = None, - white: Optional[VectorLike] = None - ) -> 'Color': + scale_space: str | None = None, + white: VectorLike | None = None + ) -> Color: """ Create a color from chromaticity coordinates. @@ -772,7 +787,7 @@ def convert_chromaticity( cspace2: str, coords: VectorLike, *, - white: Optional[VectorLike] = None + white: VectorLike | None = None ) -> Vector: """ Convert to or from chromaticity coordinates or between other chromaticity coordinates. @@ -835,7 +850,7 @@ def chromatic_adaptation( w2: VectorLike, xyz: VectorLike, *, - method: Optional[str] = None + method: str | None = None ) -> Vector: """Chromatic adaptation.""" @@ -845,27 +860,45 @@ def chromatic_adaptation( return adapter.adapt(tuple(w1), tuple(w2), xyz) # type: ignore[arg-type] - def clip(self, space: Optional[str] = None) -> 'Color': + def clip(self, space: str | None = None) -> Color: """Clip the color channels.""" orig_space = self.space() - if space is None: - space = self.space() + target_space = space or orig_space - # Convert to desired space - c = self.convert(space, in_place=True, norm=False) - gamut.clip_channels(c) + # We are indirectly clipping this space + if orig_space != target_space: + return self.convert(target_space, norm=False, in_place=True).clip().convert(orig_space, in_place=True) - # Adjust "this" color - return c.convert(orig_space, in_place=True) + # Determine what space we actually need to clip in + if space is None: + space = self._space.CLIP_SPACE or self._space.GAMUT_CHECK or orig_space + else: + cs = self.CS_MAP[space] + space = cs.CLIP_SPACE or cs.GAMUT_CHECK or cs.NAME + + # Convert to desired space and clip the color + if space != orig_space: + conv = self.convert(space, norm=False) + if not gamut.clip_channels(conv): + # Clipping only made non-essential changes (normalize hue), + # just clip in the current space to preserve 'None' and clean up noise + # at color space boundary limits (if any). + gamut.clip_channels(self) + return self + # Copy results to current color. + return self._hotswap(conv.convert(orig_space, in_place=True)) + + gamut.clip_channels(self) + return self def fit( self, - space: Optional[str] = None, + space: str | None = None, *, - method: Optional[str] = None, + method: str | None = None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Fit the gamut using the provided method.""" if method is None: @@ -875,9 +908,17 @@ def fit( if method == 'clip': return self.clip(space) - orig_space = self.space() + # If within gamut, just normalize hue range by calling clip. + if self.in_gamut(space, tolerance=0): + self.clip(space) + return self + + # Determine what space we actually need to gamut map in if space is None: - space = self.space() + target = self._space.GAMUT_CHECK or self.space() + else: + cs = self.CS_MAP[space] + target = cs.GAMUT_CHECK or cs.NAME # Select appropriate mapping algorithm mapping = self.FIT_MAP.get(method) @@ -885,21 +926,10 @@ def fit( # Unknown fit method raise ValueError("'{}' gamut mapping is not currently supported".format(method)) - # Convert to desired space - self.convert(space, in_place=True, norm=False) - - # If within gamut, just normalize hue range by calling clip. - if self.in_gamut(tolerance=0): - gamut.clip_channels(self) - - # Perform gamut mapping. - else: - mapping.fit(self, **kwargs) - - # Convert back to the original color space - return self.convert(orig_space, in_place=True) + mapping.fit(self, target, **kwargs) + return self - def in_gamut(self, space: Optional[str] = None, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool: + def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool: """Check if current color is in gamut.""" if space is None: @@ -924,12 +954,12 @@ def in_pointer_gamut(self, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool return gamut.pointer.in_pointer_gamut(self, tolerance) - def fit_pointer_gamut(self) -> 'Color': + def fit_pointer_gamut(self) -> Color: """Check if in pointer gamut.""" return gamut.pointer.fit_pointer_gamut(self) - def mask(self, channel: Union[str, Sequence[str]], *, invert: bool = False, in_place: bool = False) -> 'Color': + def mask(self, channel: str | Sequence[str], *, invert: bool = False, in_place: bool = False) -> Color: """Mask color channels.""" this = self if in_place else self.clone() @@ -939,7 +969,7 @@ def mask(self, channel: Union[str, Sequence[str]], *, invert: bool = False, in_p ) for name in self._space.channels: if (not invert and name in masks) or (invert and name not in masks): - this[name] = alg.nan + this[name] = math.nan return this def mix( @@ -949,7 +979,7 @@ def mix( *, in_place: bool = False, **interpolate_args: Any - ) -> 'Color': + ) -> Color: """ Mix colors using interpolation. @@ -970,14 +1000,15 @@ def mix( @classmethod def steps( cls, - colors: Sequence[Union[ColorInput, interpolate.stop, Callable[..., float]]], + colors: Sequence[ColorInput | interpolate.stop | Callable[..., float]], *, steps: int = 2, max_steps: int = 1000, max_delta_e: float = 0, - delta_e: Optional[str] = None, + delta_e: str | None = None, + delta_e_args: dict[str, Any] | None = None, **interpolate_args: Any - ) -> List['Color']: + ) -> list[Color]: """Discrete steps.""" # Scale really needs to be between 0 and 1 or steps will break @@ -985,20 +1016,21 @@ def steps( if domain is not None: interpolate_args['domain'] = interpolate.normalize_domain(domain) - return cls.interpolate(colors, **interpolate_args).steps(steps, max_steps, max_delta_e, delta_e) + return cls.interpolate(colors, **interpolate_args).steps(steps, max_steps, max_delta_e, delta_e, delta_e_args) @classmethod def discrete( cls, - colors: Sequence[Union[ColorInput, interpolate.stop, Callable[..., float]]], + colors: Sequence[ColorInput | interpolate.stop | Callable[..., float]], *, - space: Union[str, None] = None, - out_space: Union[str, None] = None, - steps: Union[int, None] = None, + space: str | None = None, + out_space: str | None = None, + steps: int | None = None, max_steps: int = 1000, max_delta_e: float = 0, - delta_e: Union[str, None] = None, - domain: Optional[List[float]] = None, + delta_e: str | None = None, + delta_e_args: dict[str, Any] | None = None, + domain: Vector | None = None, **interpolate_args: Any ) -> Interpolator: """Create a discrete interpolation.""" @@ -1007,7 +1039,7 @@ def discrete( num = sum((not callable(c) or not isinstance(c, interpolate.stop)) for c in colors) if steps is None else steps i = cls.interpolate(colors, space=space, **interpolate_args) # Convert the interpolation into a discretized interpolation with the requested number of steps - i.discretize(num, max_steps, max_delta_e, delta_e) + i = i.discretize(num, max_steps, max_delta_e, delta_e, delta_e_args) if domain is not None: i.domain(domain) if out_space is not None: @@ -1017,19 +1049,19 @@ def discrete( @classmethod def interpolate( cls, - colors: Sequence[Union[ColorInput, interpolate.stop, Callable[..., float]]], + colors: Sequence[ColorInput | interpolate.stop | Callable[..., float]], *, - space: Optional[str] = None, - out_space: Optional[str] = None, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]] = None, + space: str | None = None, + out_space: str | None = None, + progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None = None, hue: str = util.DEF_HUE_ADJ, premultiplied: bool = True, extrapolate: bool = False, - domain: Optional[List[float]] = None, - method: str = "linear", - padding: Optional[Union[float, Tuple[float, float]]] = None, - carryforward: Optional[bool] = None, - powerless: Optional[bool] = None, + domain: Vector | None = None, + method: str | None = None, + padding: float | tuple[float, float] | None = None, + carryforward: bool | None = None, + powerless: bool | None = None, **kwargs: Any ) -> Interpolator: """ @@ -1045,7 +1077,7 @@ def interpolate( """ return interpolate.interpolator( - method, + method if method is not None else cls.INTERPOLATOR, cls, colors=colors, space=space, @@ -1066,12 +1098,12 @@ def average( cls, colors: Iterable[ColorInput], *, - space: Optional[str] = None, - out_space: Optional[str] = None, + space: str | None = None, + out_space: str | None = None, premultiplied: bool = True, - powerless: Optional[bool] = None, + powerless: bool | None = None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Average the colors.""" if space is None: @@ -1091,13 +1123,13 @@ def average( def filter( # noqa: A003 self, name: str, - amount: Optional[float] = None, + amount: float | None = None, *, - space: Optional[str] = None, - out_space: Optional[str] = None, + space: str | None = None, + out_space: str | None = None, in_place: bool = False, **kwargs: Any - ) -> 'Color': + ) -> Color: """Filter.""" return filters.filters(self, name, amount, space, out_space, in_place, **kwargs) @@ -1106,9 +1138,10 @@ def harmony( self, name: str, *, - space: Optional[str] = None, - out_space: Optional[str] = None - ) -> List['Color']: + space: str | None = None, + out_space: str | None = None, + **kwargs: Any + ) -> list[Color]: """Acquire the specified color harmonies.""" if space is None: @@ -1121,14 +1154,14 @@ def harmony( def compose( self, - backdrop: Union[ColorInput, Sequence[ColorInput]], + backdrop: ColorInput | Sequence[ColorInput], *, - blend: Union[str, bool] = True, - operator: Union[str, bool] = True, - space: Optional[str] = None, - out_space: Optional[str] = None, + blend: str | bool = True, + operator: str | bool = True, + space: str | None = None, + out_space: str | None = None, in_place: bool = False - ) -> 'Color': + ) -> Color: """Blend colors using the specified blend mode.""" if not isinstance(backdrop, str) and isinstance(backdrop, Sequence): @@ -1143,7 +1176,7 @@ def delta_e( self, color: ColorInput, *, - method: Optional[str] = None, + method: str | None = None, **kwargs: Any ) -> float: """Delta E distance.""" @@ -1166,14 +1199,14 @@ def closest( self, colors: Sequence[ColorInput], *, - method: Optional[str] = None, + method: str | None = None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Find the closest color to the current base color.""" return distance.closest(self, colors, method=method, **kwargs) - def luminance(self, *, white: Optional[VectorLike] = cat.WHITES['2deg']['D65']) -> float: + def luminance(self, *, white: VectorLike | None = cat.WHITES['2deg']['D65']) -> float: """Get color's luminance.""" if white is None: @@ -1190,7 +1223,7 @@ def luminance(self, *, white: Optional[VectorLike] = cat.WHITES['2deg']['D65']) return coords[1] - def contrast(self, color: ColorInput, method: Optional[str] = None) -> float: + def contrast(self, color: ColorInput, method: str | None = None) -> float: """Compare the contrast ratio of this color and the provided color.""" color = self._handle_color_input(color) @@ -1201,10 +1234,10 @@ def get(self, name: str, *, nans: bool = True) -> float: ... @overload - def get(self, name: Union[List[str], Tuple[str, ...]], *, nans: bool = True) -> List[float]: + def get(self, name: list[str] | tuple[str, ...], *, nans: bool = True) -> Vector: ... - def get(self, name: Union[str, List[str], Tuple[str, ...]], *, nans: bool = True) -> Union[float, List[float]]: + def get(self, name: str | list[str] | tuple[str, ...], *, nans: bool = True) -> float | Vector: """Get channel.""" # Handle single channel @@ -1245,11 +1278,11 @@ def get(self, name: Union[str, List[str], Tuple[str, ...]], *, nans: bool = True def set( # noqa: A003 self, - name: Union[str, Dict[str, Union[float, Callable[..., float]]]], - value: Optional[Union[float, Callable[..., float]]] = None, + name: str | dict[str, float | Callable[..., float]], + value: float | Callable[..., float] | None = None, *, nans: bool = True - ) -> 'Color': + ) -> Color: """Set channel.""" # Set all the channels in a dictionary. @@ -1334,11 +1367,17 @@ def alpha(self, *, nans: bool = True) -> float: LCh(), LabD65(), LChD65(), + Jzazbz(), + JzCzhz(), + ICtCp(), HSV(), HSL(), HWB(), Rec2020(), Rec2020Linear(), + Rec2100PQ(), + Rec2100HLG(), + Rec2100Linear(), A98RGB(), A98RGBLinear(), ProPhotoRGB(), @@ -1354,10 +1393,15 @@ def alpha(self, *, nans: bool = True) -> float: DE2000(), DEHyAB(), DEOK(), + DEITP(), + DEZ(), # Fit LChChroma(), OkLChChroma(), + RayTrace(), + LChRayTrace(), + OkLChRayTrace(), # Filters Sepia(), @@ -1377,6 +1421,7 @@ def alpha(self, *, nans: bool = True) -> float: # Interpolation Linear(), + CSSLinear(), Continuous(), BSpline(), NaturalBSpline(), diff --git a/lib/coloraide/compositing/__init__.py b/lib/coloraide/compositing/__init__.py index e3aa6e8..b777a21 100644 --- a/lib/coloraide/compositing/__init__.py +++ b/lib/coloraide/compositing/__init__.py @@ -3,12 +3,13 @@ https://www.w3.org/TR/compositing/ """ +from __future__ import annotations from .. spaces import RGBish from . import porter_duff from . import blend_modes from .. import algebra as alg from ..channels import Channel -from typing import Optional, Union, List, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -17,8 +18,8 @@ def clip_channel(coord: float, channel: Channel) -> float: """Clipping channel.""" - a = channel.low # type: Optional[float] - b = channel.high # type: Optional[float] + a = channel.low # type: float | None + b = channel.high # type: float | None # These parameters are unbounded if not channel.bound: # pragma: no cover @@ -32,11 +33,11 @@ def clip_channel(coord: float, channel: Channel) -> float: def apply_compositing( - color1: 'Color', - color2: 'Color', - blender: Optional[blend_modes.Blend], - operator: Union[str, bool] -) -> 'Color': + color1: Color, + color2: Color, + blender: blend_modes.Blend | None, + operator: str | bool +) -> Color: """Perform the actual blending.""" # Get the color coordinates @@ -46,7 +47,7 @@ def apply_compositing( coords2 = color2.coords(nans=False) # Setup compositing - compositor = None # type: Optional[porter_duff.PorterDuff] + compositor = None # type: porter_duff.PorterDuff | None cra = csa if isinstance(operator, str): compositor = porter_duff.compositor(operator)(cba, csa) @@ -75,17 +76,17 @@ def apply_compositing( def compose( - color: 'Color', - backdrop: List['Color'], - blend: Union[str, bool] = True, - operator: Union[str, bool] = True, - space: Optional[str] = None, - out_space: Optional[str] = None -) -> 'Color': + color: Color, + backdrop: list[Color], + blend: str | bool = True, + operator: str | bool = True, + space: str | None = None, + out_space: str | None = None +) -> Color: """Blend colors using the specified blend mode.""" # We need to go ahead and grab the blender as we need to check what type of blender it is. - blender = None # Optional[blend_modes.Blend] + blender = None # blend_modes.Blend | None if isinstance(blend, str): blender = blend_modes.get_blender(blend) elif blend is True: diff --git a/lib/coloraide/compositing/blend_modes.py b/lib/coloraide/compositing/blend_modes.py index f7438c3..d142934 100644 --- a/lib/coloraide/compositing/blend_modes.py +++ b/lib/coloraide/compositing/blend_modes.py @@ -1,8 +1,8 @@ """Blend modes.""" +from __future__ import annotations import math from abc import ABCMeta, abstractmethod from operator import itemgetter -from typing import Dict from ..types import Vector @@ -298,7 +298,7 @@ def apply(self, cb: Vector, cs: Vector) -> Vector: "saturation": BlendSaturation(), "luminosity": BlendLuminosity(), "color": BlendColor(), -} # type: Dict[str, Blend] +} # type: dict[str, Blend] def get_blender(blend: str) -> Blend: diff --git a/lib/coloraide/compositing/porter_duff.py b/lib/coloraide/compositing/porter_duff.py index 1be4c7c..f32500f 100644 --- a/lib/coloraide/compositing/porter_duff.py +++ b/lib/coloraide/compositing/porter_duff.py @@ -1,6 +1,6 @@ """Porter Duff compositing.""" +from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import Type class PorterDuff(metaclass=ABCMeta): @@ -234,7 +234,7 @@ def fb(self) -> float: } -def compositor(name: str) -> Type[PorterDuff]: +def compositor(name: str) -> type[PorterDuff]: """Get the requested compositor.""" composite = SUPPORTED.get(name) diff --git a/lib/coloraide/contrast/__init__.py b/lib/coloraide/contrast/__init__.py index 1655c95..efc4157 100644 --- a/lib/coloraide/contrast/__init__.py +++ b/lib/coloraide/contrast/__init__.py @@ -1,7 +1,8 @@ """Contrast.""" +from __future__ import annotations from abc import ABCMeta, abstractmethod from ..types import Plugin -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -13,11 +14,11 @@ class ColorContrast(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def contrast(self, color1: 'Color', color2: 'Color', **kwargs: Any) -> float: + def contrast(self, color1: Color, color2: Color, **kwargs: Any) -> float: """Get the contrast of the two provided colors.""" -def contrast(name: Optional[str], color1: 'Color', color2: 'Color', **kwargs: Any) -> float: +def contrast(name: str | None, color1: Color, color2: Color, **kwargs: Any) -> float: """Get the appropriate contrast plugin.""" if name is None: diff --git a/lib/coloraide/contrast/lstar.py b/lib/coloraide/contrast/lstar.py index 0674fb5..74c3ecd 100644 --- a/lib/coloraide/contrast/lstar.py +++ b/lib/coloraide/contrast/lstar.py @@ -5,6 +5,7 @@ https://material.io/blog/science-of-color-design """ +from __future__ import annotations from ..contrast import ColorContrast from typing import Any, TYPE_CHECKING @@ -17,7 +18,7 @@ class LstarContrast(ColorContrast): NAME = "lstar" - def contrast(self, color1: 'Color', color2: 'Color', **kwargs: Any) -> float: + def contrast(self, color1: Color, color2: Color, **kwargs: Any) -> float: """Contrast.""" l1 = color1.get('lch-d65.lightness', nans=False) diff --git a/lib/coloraide/contrast/wcag21.py b/lib/coloraide/contrast/wcag21.py index 5e0e557..e8a9a38 100644 --- a/lib/coloraide/contrast/wcag21.py +++ b/lib/coloraide/contrast/wcag21.py @@ -3,6 +3,7 @@ https://www.w3.org/TR/WCAG20/#contrast-ratiodef """ +from __future__ import annotations from ..contrast import ColorContrast from typing import Any, TYPE_CHECKING @@ -15,7 +16,7 @@ class WCAG21Contrast(ColorContrast): NAME = "wcag21" - def contrast(self, color1: 'Color', color2: 'Color', **kwargs: Any) -> float: + def contrast(self, color1: Color, color2: Color, **kwargs: Any) -> float: """Contrast.""" lum1 = max(0, color1.luminance()) diff --git a/lib/coloraide/convert.py b/lib/coloraide/convert.py index c6dcf9f..977dcdd 100644 --- a/lib/coloraide/convert.py +++ b/lib/coloraide/convert.py @@ -1,7 +1,7 @@ """Convert the color.""" -from . import algebra as alg +from __future__ import annotations from .types import Vector -from typing import Type, Tuple, Dict, List, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from .color import Color @@ -13,9 +13,9 @@ def calc_path_to_xyz( - color: Type['Color'], + color: type[Color], space: str -) -> Tuple[List['Space'], Dict[str, int]]: +) -> tuple[list[Space], dict[str, int]]: """ Calculate the conversion path between a given color space and XYZ D65. @@ -53,10 +53,10 @@ def calc_path_to_xyz( def get_convert_chain( - color: Type['Color'], - space: 'Space', + color: type[Color], + space: Space, target: str -) -> List[Tuple['Space', 'Space', int, bool]]: +) -> list[tuple[Space, Space, int, bool]]: """ Create a conversion chain. @@ -74,7 +74,7 @@ def get_convert_chain( # If the color space we are converting to is not between # the current space and XYZ D65, nothing will get added. current = space - chain = [] # type: List[Tuple['Space', 'Space', int, bool]] + chain = [] # type: list[tuple[Space, Space, int, bool]] if current.NAME != ABSOLUTE_BASE: count = 0 while current.NAME not in from_color_index: @@ -119,7 +119,7 @@ def get_convert_chain( return chain -def convert(color: 'Color', space: str) -> Tuple['Space', Vector]: +def convert(color: Color, space: str) -> tuple[Space, Vector]: """Convert the color coordinates to the specified space.""" # Grab the convert for the current space to the desired space diff --git a/lib/coloraide/css/color_names.py b/lib/coloraide/css/color_names.py index 5758ca1..04d5207 100644 --- a/lib/coloraide/css/color_names.py +++ b/lib/coloraide/css/color_names.py @@ -5,7 +5,7 @@ http://www.w3.org/TR/SVG/types.html#ColorKeywords """ -from typing import Optional, Tuple, Dict +from __future__ import annotations from .. import algebra as alg from ..types import Vector @@ -161,18 +161,18 @@ # Transparent 'transparent': (0.0, 0.0, 0.0, 0.0) -} # type: Dict[str, Tuple[float, ...]] +} # type: dict[str, tuple[float, ...]] -val2name_map = {v: k for k, v in name2val_map.items()} # type: Dict[Tuple[float, ...], str] +val2name_map = {v: k for k, v in name2val_map.items()} # type: dict[tuple[float, ...], str] -def to_name(value: Vector) -> Optional[str]: +def to_name(value: Vector) -> str | None: """Convert CSS hex to webcolor name.""" return val2name_map.get(tuple(alg.round_half_up(c * 255) for c in value), None) -def from_name(name: str) -> Optional[Vector]: +def from_name(name: str) -> Vector | None: """Convert CSS hex to webcolor name.""" value = name2val_map.get(name.lower(), None) diff --git a/lib/coloraide/css/parse.py b/lib/coloraide/css/parse.py index 874bee7..4b5a05c 100644 --- a/lib/coloraide/css/parse.py +++ b/lib/coloraide/css/parse.py @@ -1,12 +1,12 @@ """Parse utilities.""" +from __future__ import annotations import re import math from .. import algebra as alg from ..types import Vector from . import color_names from ..channels import Channel, FLG_ANGLE -from typing import Optional, Tuple -from typing import List, Dict, Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any import functools if TYPE_CHECKING: # pragma: no cover @@ -37,7 +37,7 @@ def norm_float(string: str) -> float: """Normalize a float value.""" if string == "none": - return alg.nan + return math.nan return float(string) @@ -114,7 +114,7 @@ def norm_angle_channel(angle: str) -> float: return value -def parse_hex(color: str) -> Tuple[Vector, float]: +def parse_hex(color: str) -> tuple[Vector, float]: """Parse hexadecimal color.""" length = len(color) @@ -138,7 +138,7 @@ def parse_hex(color: str) -> Tuple[Vector, float]: ) -def parse_rgb_channels(color: List[str], boundry: Tuple[Channel, ...]) -> Tuple[Vector, float]: +def parse_rgb_channels(color: list[str], boundry: tuple[Channel, ...]) -> tuple[Vector, float]: """Parse CSS RGB format.""" channels = [] @@ -152,7 +152,7 @@ def parse_rgb_channels(color: List[str], boundry: Tuple[Channel, ...]) -> Tuple[ return channels, alpha -def parse_channels(color: List[str], boundry: Tuple[Channel, ...], scaled: bool = False) -> Tuple[Vector, float]: +def parse_channels(color: list[str], boundry: tuple[Channel, ...], scaled: bool = False) -> tuple[Vector, float]: """Parse CSS channel format.""" channels = [] @@ -173,7 +173,7 @@ def parse_channels(color: List[str], boundry: Tuple[Channel, ...], scaled: bool return channels, alpha -def parse_color(tokens: Dict[str, Any], space: 'Space') -> Optional[Tuple[Vector, float]]: +def parse_color(tokens: dict[str, Any], space: Space) -> tuple[Vector, float] | None: """Parse the color function.""" # Iterate the spaces and see if we find the color serialization identifier @@ -193,17 +193,20 @@ def parse_color(tokens: Dict[str, Any], space: 'Space') -> Optional[Tuple[Vector for i in range(num_channels): c = tokens['func']['values'][i]['value'] channel = properties[i] - channels.append(norm_color_channel(c.lower(), channel.span, channel.offset)) + if channel.flags & FLG_ANGLE: + channels.append(norm_angle_channel(c)) + else: + channels.append(norm_color_channel(c.lower(), channel.span, channel.offset)) return (channels, alpha) -def validate_color(tokens: Dict[str, Any]) -> bool: +def validate_color(tokens: dict[str, Any]) -> bool: """Validate the color function syntax.""" - return not any(v['type'] == 'degree' for v in tokens['func']['values']) + return True -def validate_srgb(tokens: Dict[str, Any]) -> bool: +def validate_srgb(tokens: dict[str, Any]) -> bool: """Validate the RGB color functions.""" length = len(tokens['func']['values']) @@ -225,7 +228,7 @@ def validate_srgb(tokens: Dict[str, Any]) -> bool: return True -def validate_cylindrical_srgb(tokens: Dict[str, Any]) -> bool: +def validate_cylindrical_srgb(tokens: dict[str, Any]) -> bool: """Validate cylindrical sRGB.""" length = len(tokens['func']['values']) @@ -256,7 +259,7 @@ def validate_cylindrical_srgb(tokens: Dict[str, Any]) -> bool: return True -def validate_lab(tokens: Dict[str, Any]) -> bool: +def validate_lab(tokens: dict[str, Any]) -> bool: """Validate CSS Lab variant color spaces.""" length = len(tokens['func']['values']) @@ -277,7 +280,7 @@ def validate_lab(tokens: Dict[str, Any]) -> bool: return True -def validate_lch(tokens: Dict[str, Any]) -> bool: +def validate_lch(tokens: dict[str, Any]) -> bool: """Validate CSS LCh variant color spaces.""" length = len(tokens['func']['values']) @@ -302,10 +305,10 @@ def validate_lch(tokens: Dict[str, Any]) -> bool: @functools.lru_cache(maxsize=1) -def tokenize_css(css: str, start: int = 0) -> Dict[str, Any]: +def tokenize_css(css: str, start: int = 0) -> dict[str, Any]: """Tokenize the CSS string.""" - tokens = {} # type: Dict[str, Any] + tokens = {} # type: dict[str, Any] # `mypy` will get confused, just set to Any m = RE_HEX.match(css, start) # type: Any if m: @@ -410,7 +413,7 @@ def tokenize_css(css: str, start: int = 0) -> Dict[str, Any]: # Do basic validation on the supported color functions tokens['end'] = m.end() if func_name == 'color' and not validate_color(tokens): - return {} + return {} # pragma: no cover elif func_name.startswith('rgb'): tokens['id'] = 'srgb' @@ -439,12 +442,12 @@ def tokenize_css(css: str, start: int = 0) -> Dict[str, Any]: def parse_css( - cspace: 'Space', + cspace: Space, string: str, start: int = 0, fullmatch: bool = True, color: bool = False -) -> Optional[Tuple[Tuple[Vector, float], int]]: +) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" target = cspace.SERIALIZE diff --git a/lib/coloraide/css/serialize.py b/lib/coloraide/css/serialize.py index b795953..e003218 100644 --- a/lib/coloraide/css/serialize.py +++ b/lib/coloraide/css/serialize.py @@ -1,12 +1,13 @@ """String serialization.""" +from __future__ import annotations import re import math from .. import util from .. import algebra as alg from .color_names import to_name -from ..channels import FLG_PERCENT, FLG_OPT_PERCENT, FLG_ANGLE +from ..channels import FLG_ANGLE from ..types import Vector -from typing import Optional, Union, TYPE_CHECKING +from typing import TYPE_CHECKING, Sequence, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -21,9 +22,9 @@ def named_color( obj: 'Color', - alpha: Optional[bool], - fit: Union[str, bool] -) -> Optional[str]: + alpha: bool | None, + fit: str | bool | dict[str, Any] +) -> str | None: """Get the CSS color name.""" a = get_alpha(obj, alpha, False, False) @@ -32,90 +33,102 @@ def named_color( return to_name(get_coords(obj, fit, False, False) + [a]) -def named_color_function( +def color_function( obj: 'Color', - func: str, - alpha: Optional[bool], + func: str | None, + alpha: bool | None, precision: int, - fit: Union[str, bool], + fit: str | bool | dict[str, Any], none: bool, - percent: bool, + percent: bool | Sequence[bool], legacy: bool, scale: float ) -> str: """Translate to CSS function form `name(...)`.""" - # Create the function `name` or `namea` if old legacy form. + # Prepare coordinates to be serialized a = get_alpha(obj, alpha, none, legacy) - string = ['{}{}('.format(func, 'a' if legacy and a is not None else EMPTY)] - - # Iterate the coordinates formatting them for percent, not percent, and even scaling them (sRGB). coords = get_coords(obj, fit, none, legacy) - channels = obj._space.CHANNELS + if a is not None: + coords.append(a) + + # `color` should include the color space serialized name. + if func is None: + string = ['color({} '.format(obj._space._serialize()[0])] + # Create the function `name` or `namea` if old legacy form. + else: + string = ['{}{}('.format(func, 'a' if legacy and a is not None else EMPTY)] + + # Get channel object and calculate length and the alpha index (last) + channels = obj._space.channels + l = len(channels) + last = l - 1 + + # Ensure percent is configured + # - `True` assumes all but alpha are attempted to be formatted as percents. + # - A list of booleans will attempt formatting the associated channel as percent, + # anything not specified is assumed `False`. + if isinstance(percent, bool): + plist = obj._space._percents if percent else [] + else: + diff = l - len(percent) + plist = list(percent) + ([False] * diff) if diff > 0 else list(percent) + + # Iterate the coordinates formatting them by scaling the values, formatting for percent, etc. for idx, value in enumerate(coords): - channel = channels[idx] - use_percent = channel.flags & FLG_PERCENT or (percent and channel.flags & FLG_OPT_PERCENT) - is_angle = channel.flags & FLG_ANGLE - if not use_percent and not is_angle: - value *= scale - if idx != 0: + is_last = idx == last + if is_last: + string.append(COMMA if legacy else SLASH) + elif idx != 0: string.append(COMMA if legacy else SPACE) + channel = channels[idx] + + if not (channel.flags & FLG_ANGLE) and plist and plist[idx]: + span, offset = channel.span, channel.offset + else: + span = offset = 0.0 + if not channel.flags & FLG_ANGLE and not is_last: + value *= scale + string.append( util.fmt_float( value, precision, - channel.span if use_percent else 0.0, - channel.offset if use_percent else 0.0 + span, + offset ) ) - # Add alpha if needed - if a is not None: - string.append('{}{})'.format(COMMA if legacy else SLASH, util.fmt_float(a, max(precision, util.DEF_PREC)))) - else: - string.append(')') + string.append(')') return EMPTY.join(string) -def color_function( - obj: 'Color', - alpha: Optional[bool], - precision: int, - fit: Union[str, bool], - none: bool -) -> str: - """Color format.""" - - # Export in the `color(space ...)` format - coords = get_coords(obj, fit, none, False) - a = get_alpha(obj, alpha, none, False) - return ( - 'color({} {}{})'.format( - obj._space._serialize()[0], - SPACE.join([util.fmt_float(coord, precision) for coord in coords]), - SLASH + util.fmt_float(a, max(precision, util.DEF_PREC)) if a is not None else EMPTY - ) - ) - - def get_coords( obj: 'Color', - fit: Union[str, bool], + fit: bool | str | dict[str, Any], none: bool, legacy: bool ) -> Vector: """Get the coordinates.""" - color = (obj.fit(method=None if not isinstance(fit, str) else fit) if fit else obj) + if fit: + if fit is True: + color = obj.fit() + elif isinstance(fit, str): + color = obj.fit(method=fit) + else: + color = obj.fit(**fit) + else: + color = obj return color.coords(nans=False if legacy or not none else True) def get_alpha( obj: 'Color', - alpha: Optional[bool], + alpha: bool | None, none: bool, legacy: bool -) -> Optional[float]: +) -> float | None: """Get the alpha if required.""" a = obj.alpha(nans=False if not none or legacy else True) @@ -125,8 +138,8 @@ def get_alpha( def hexadecimal( obj: 'Color', - alpha: Optional[bool] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + fit: str | bool | dict[str, Any] = True, upper: bool = False, compress: bool = False ) -> str: @@ -163,11 +176,11 @@ def serialize_css( obj: 'Color', func: str = '', color: bool = False, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, hexa: bool = False, upper: bool = False, compress: bool = False, @@ -182,7 +195,7 @@ def serialize_css( # Color format if color: - return color_function(obj, alpha, precision, fit, none) + return color_function(obj, None, alpha, precision, fit, none, percent, False, 1.0) # CSS color names if name: @@ -196,6 +209,6 @@ def serialize_css( # Normal CSS named function format if func: - return named_color_function(obj, func, alpha, precision, fit, none, percent, legacy, scale) + return color_function(obj, func, alpha, precision, fit, none, percent, legacy, scale) raise RuntimeError('Could not identify a CSS format to serialize to') # pragma: no cover diff --git a/lib/coloraide/deprecate.py b/lib/coloraide/deprecate.py index 278232a..12b04d9 100644 --- a/lib/coloraide/deprecate.py +++ b/lib/coloraide/deprecate.py @@ -1,10 +1,11 @@ """Deprecation functions.""" +from __future__ import annotations import warnings from functools import wraps from typing import Any, Callable -def deprecated(message: str, stacklevel: int = 2) -> Callable[..., Any]: # pragma: no cover +def deprecated(message: str, stacklevel: int = 2) -> Callable[..., Any]: """ Raise a `DeprecationWarning` when wrapped function/method is called. @@ -28,7 +29,7 @@ def _deprecated_func(*args: Any, **kwargs: Any) -> Any: return _wrapper -def warn_deprecated(message: str, stacklevel: int = 2) -> None: # pragma: no cover +def warn_deprecated(message: str, stacklevel: int = 2) -> None: """Warn deprecated.""" warnings.warn( diff --git a/lib/coloraide/distance/__init__.py b/lib/coloraide/distance/__init__.py index 1962ae8..89f4da0 100644 --- a/lib/coloraide/distance/__init__.py +++ b/lib/coloraide/distance/__init__.py @@ -1,15 +1,16 @@ """Distance and Delta E.""" -from abc import ABCMeta, abstractmethod +from __future__ import annotations import math from .. import algebra as alg +from abc import ABCMeta, abstractmethod from ..types import ColorInput, Plugin -from typing import TYPE_CHECKING, Any, Sequence, Optional +from typing import TYPE_CHECKING, Any, Sequence if TYPE_CHECKING: # pragma: no cover from ..color import Color -def closest(color: 'Color', colors: Sequence[ColorInput], method: Optional[str] = None, **kwargs: Any) -> 'Color': +def closest(color: Color, colors: Sequence[ColorInput], method: str | None = None, **kwargs: Any) -> Color: """Get the closest color.""" if method is None: @@ -19,7 +20,7 @@ def closest(color: 'Color', colors: Sequence[ColorInput], method: Optional[str] if not algorithm: raise ValueError("'{}' is not currently a supported distancing algorithm.".format(method)) - lowest = alg.inf + lowest = math.inf closest = None for c in colors: color2 = color._handle_color_input(c) @@ -34,15 +35,29 @@ def closest(color: 'Color', colors: Sequence[ColorInput], method: Optional[str] return closest -def distance_euclidean(color: 'Color', sample: 'Color', space: str = "lab-d65") -> float: +def distance_euclidean(color: Color, sample: Color, space: str = "lab-d65") -> float: """ Euclidean distance. https://en.wikipedia.org/wiki/Euclidean_distance """ - coords1 = color.convert(space, norm=False).coords(nans=False) - coords2 = sample.convert(space, norm=False).coords(nans=False) + # convert to the specified space + c1 = color.convert(space, norm=False) + c2 = sample.convert(space, norm=False) + coords1 = c1.coords(nans=False) + coords2 = c2.coords(nans=False) + + # Convert polar coordinate into rectangular coordinates + if c1._space.is_polar(): + hi = c1._space.hue_index() # type: ignore[attr-defined] + ri = c1._space.radial_index() # type: ignore[attr-defined] + a, b = alg.polar_to_rect(coords1[ri], coords1[hi]) + coords1[hi] = a + coords1[ri] = b + a, b = alg.polar_to_rect(coords2[ri], coords2[hi]) + coords2[hi] = a + coords2[ri] = b return math.sqrt(sum((x - y) ** 2.0 for x, y in zip(coords1, coords2))) @@ -53,5 +68,5 @@ class DeltaE(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def distance(self, color: 'Color', sample: 'Color', **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: """Get distance between color and sample.""" diff --git a/lib/coloraide/distance/delta_e_2000.py b/lib/coloraide/distance/delta_e_2000.py index 440d8e9..71c3edd 100644 --- a/lib/coloraide/distance/delta_e_2000.py +++ b/lib/coloraide/distance/delta_e_2000.py @@ -1,7 +1,8 @@ """Delta E 2000.""" +from __future__ import annotations import math -from .. import algebra as alg from ..distance import DeltaE +from ..spaces.lab import CIELab from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover @@ -12,26 +13,30 @@ class DE2000(DeltaE): """Delta E 2000 class.""" NAME = "2000" - - # CSS uses D50 because that is the only Lab in the spec, - # but most implementation use D65. Typically D50 is the - # choice for reflective (i.e. Paper) or transmissive readings, - # while displays would typically use a measured white reference, - # or D65. If a CSS compliant variant is desired, simply subclass - # and set `LAB` to `lab-d50`. If the intent is not to override, - # then set `NAME` to something like `NAME="2000-D50"`. - LAB = "lab-d65" - G_CONST = 25 ** 7 - @classmethod - def distance( - cls, - color: 'Color', - sample: 'Color', + def __init__( + self, kl: float = 1, kc: float = 1, kh: float = 1, + space: str = 'lab-d65' + ): + """Initialize.""" + + self.kl = kl + self.kc = kc + self.kh = kh + self.space = space + + def distance( + self, + color: Color, + sample: Color, + kl: float | None = None, + kc: float | None = None, + kh: float | None = None, + space: str | None = None, **kwargs: Any ) -> float: """ @@ -43,8 +48,22 @@ def distance( http://www2.ece.rochester.edu/~gsharma/ciede2000/ciede2000noteCRNA.pdf """ - l1, a1, b1 = color.convert(cls.LAB).coords(nans=False) - l2, a2, b2 = sample.convert(cls.LAB).coords(nans=False) + if kl is None: + kl = self.kl + + if kc is None: + kc = self.kc + + if kh is None: + kh = self.kh + + if space is None: + space = self.space + if not isinstance(color.CS_MAP[space], CIELab): + raise ValueError("Distance color space must be a CIE Lab color space.") + + l1, a1, b1 = color.convert(space).coords(nans=False) + l2, a2, b2 = sample.convert(space).coords(nans=False) # Equation (2) c1 = math.sqrt(a1 ** 2 + b1 ** 2) @@ -55,7 +74,7 @@ def distance( # Equation (4) c7 = cm ** 7 - g = 0.5 * (1 - math.sqrt(c7 / (c7 + cls.G_CONST))) + g = 0.5 * (1 - math.sqrt(c7 / (c7 + self.G_CONST))) # Equation (5) ap1 = (1 + g) * a1 @@ -68,8 +87,8 @@ def distance( # Equation (7) hp1 = 0 if (ap1 == 0 and b1 == 0) else math.atan2(b1, ap1) hp2 = 0 if (ap2 == 0 and b2 == 0) else math.atan2(b2, ap2) - hp1 = math.degrees(hp1 + alg.tau if hp1 < 0.0 else hp1) - hp2 = math.degrees(hp2 + alg.tau if hp2 < 0.0 else hp2) + hp1 = math.degrees(hp1 + math.tau if hp1 < 0.0 else hp1) + hp2 = math.degrees(hp2 + math.tau if hp2 < 0.0 else hp2) # Equation (8) dl = l1 - l2 @@ -124,7 +143,7 @@ def distance( # Equation (17) cpm7 = cpm ** 7 - rc = 2 * math.sqrt(cpm7 / (cpm7 + cls.G_CONST)) + rc = 2 * math.sqrt(cpm7 / (cpm7 + self.G_CONST)) # Equation (18) l_temp = (lpm - 50) ** 2 diff --git a/lib/coloraide/distance/delta_e_76.py b/lib/coloraide/distance/delta_e_76.py index c818050..140559f 100644 --- a/lib/coloraide/distance/delta_e_76.py +++ b/lib/coloraide/distance/delta_e_76.py @@ -1,7 +1,8 @@ """Delta E 76.""" +from __future__ import annotations from ..distance import DeltaE, distance_euclidean from typing import TYPE_CHECKING, Any - +from ..spaces.lab import CIELab if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -10,9 +11,19 @@ class DE76(DeltaE): """Delta E 76 class.""" NAME = "76" - SPACE = "lab-d65" - def distance(self, color: 'Color', sample: 'Color', **kwargs: Any) -> float: + def __init__(self, space: str = 'lab-d65'): + """Initialize.""" + + self.space = space + + def distance( + self, + color: Color, + sample: Color, + space: str | None = None, + **kwargs: Any + ) -> float: """ Delta E 1976 color distance formula. @@ -21,5 +32,10 @@ def distance(self, color: 'Color', sample: 'Color', **kwargs: Any) -> float: Basically this is Euclidean distance in the Lab space. """ + if space is None: + space = self.space + if not isinstance(color.CS_MAP[space], CIELab): + raise ValueError("Distance color space must be a CIE Lab color space.") + # Equation (1) - return distance_euclidean(color, sample, space=self.SPACE) + return distance_euclidean(color, sample, space=space) diff --git a/lib/coloraide/distance/delta_e_94.py b/lib/coloraide/distance/delta_e_94.py index 9aa3d49..8a71e5a 100644 --- a/lib/coloraide/distance/delta_e_94.py +++ b/lib/coloraide/distance/delta_e_94.py @@ -1,8 +1,9 @@ """Delta E 94.""" +from __future__ import annotations from ..distance import DeltaE +from ..spaces.lab import CIELab import math -from .. import algebra as alg -from typing import TYPE_CHECKING, Optional, Any +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -17,21 +18,24 @@ def __init__( self, kl: float = 1, k1: float = 0.045, - k2: float = 0.015 + k2: float = 0.015, + space: str = 'lab-d65' ): """Initialize.""" self.kl = kl self.k1 = k1 self.k2 = k2 + self.space = space def distance( self, - color: 'Color', - sample: 'Color', - kl: Optional[float] = None, - k1: Optional[float] = None, - k2: Optional[float] = None, + color: Color, + sample: Color, + kl: float | None = None, + k1: float | None = None, + k2: float | None = None, + space: str | None = None, **kwargs: Any ) -> float: """ @@ -49,8 +53,13 @@ def distance( if k2 is None: k2 = self.k2 - l1, a1, b1 = color.convert("lab").coords(nans=False) - l2, a2, b2 = sample.convert("lab").coords(nans=False) + if space is None: + space = self.space + if not isinstance(color.CS_MAP[space], CIELab): + raise ValueError("Distance color space must be a CIE Lab color space.") + + l1, a1, b1 = color.convert(space).coords(nans=False) + l2, a2, b2 = sample.convert(space).coords(nans=False) # Equation (5) c1 = math.sqrt(a1 ** 2 + b1 ** 2) diff --git a/lib/coloraide/distance/delta_e_99o.py b/lib/coloraide/distance/delta_e_99o.py index a7ddeb4..18a9a5a 100644 --- a/lib/coloraide/distance/delta_e_99o.py +++ b/lib/coloraide/distance/delta_e_99o.py @@ -3,11 +3,20 @@ https://de.wikipedia.org/wiki/DIN99-Farbraum """ -from .delta_e_76 import DE76 +from __future__ import annotations +from ..distance import DeltaE, distance_euclidean +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: # pragma: no cover + from ..color import Color -class DE99o(DE76): + +class DE99o(DeltaE): """Delta E 99o class.""" - NAME = "99o" - SPACE = "din99o" + NAME = '99o' + + def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: + """Get delta E 99o.""" + + return distance_euclidean(color, sample, space='din99o') diff --git a/lib/coloraide/distance/delta_e_cam16.py b/lib/coloraide/distance/delta_e_cam16.py index 163a70b..a80f046 100644 --- a/lib/coloraide/distance/delta_e_cam16.py +++ b/lib/coloraide/distance/delta_e_cam16.py @@ -3,25 +3,51 @@ https://www.ncbi.nlm.nih.gov/pmc/articles/PMC9698626/pdf/sensors-22-08869.pdf """ +from __future__ import annotations import math from ..distance import DeltaE +from ..deprecate import warn_deprecated from typing import Any, TYPE_CHECKING -from ..spaces.cam16_ucs import COEFFICENTS +from ..spaces.cam16_ucs import COEFFICENTS, CAM16UCS if TYPE_CHECKING: # pragma: no cover from ..color import Color +WARN_MSG = ( + "The 'model' parameter is now deprecated, please specify the CAM16 UCS/LCD/SCD space name via 'space' instead" +) + + class DECAM16(DeltaE): """Delta E CAM16 class.""" NAME = "cam16" - def distance(self, color: 'Color', sample: 'Color', model: str = 'ucs', **kwargs: Any) -> float: - """Delta E z color distance formula.""" + def distance( + self, + color: Color, + sample: Color, + space: str = "cam16-ucs", + model: str | None = None, + **kwargs: Any + ) -> float: + """Delta E CAM16 color distance formula.""" + + # Legacy approach to specifying CAM16 approach + if model is not None: # pragma: no cover + warn_deprecated(WARN_MSG) + space = 'cam16-{}'.format(model) + kl = COEFFICENTS[model][0] + + # Normal approach to specifying CAM16 target space + else: + cs = color.CS_MAP[space] + if not isinstance(color.CS_MAP[space], CAM16UCS): + raise ValueError("Distance color space must be derived from CAM16UCS.") + model = cs.MODEL # type: ignore[attr-defined] + kl = COEFFICENTS[model][0] - space = 'cam16-{}'.format(model) - kl = COEFFICENTS[model][0] j1, a1, b1 = color.convert(space).coords(nans=False) j2, a2, b2 = sample.convert(space).coords(nans=False) diff --git a/lib/coloraide/distance/delta_e_cmc.py b/lib/coloraide/distance/delta_e_cmc.py index 19968c6..c6dfe21 100644 --- a/lib/coloraide/distance/delta_e_cmc.py +++ b/lib/coloraide/distance/delta_e_cmc.py @@ -1,7 +1,9 @@ """Delta E CMC.""" +from __future__ import annotations from ..distance import DeltaE +from ..spaces.lab import CIELab import math -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -15,19 +17,22 @@ class DECMC(DeltaE): def __init__( self, l: float = 2, - c: float = 1 + c: float = 1, + space: str = 'lab-d65' ): """Initialize.""" self.l = l self.c = c + self.space = space def distance( self, - color: 'Color', - sample: 'Color', - l: Optional[float] = None, - c: Optional[float] = None, + color: Color, + sample: Color, + l: float | None = None, + c: float | None = None, + space: str | None = None, **kwargs: Any ) -> float: """ @@ -42,8 +47,13 @@ def distance( if c is None: c = self.c - l1, a1, b1 = color.convert("lab").coords(nans=False) - l2, a2, b2 = sample.convert("lab").coords(nans=False) + if space is None: + space = self.space + if not isinstance(color.CS_MAP[space], CIELab): + raise ValueError("Distance color space must be a CIE Lab color space.") + + l1, a1, b1 = color.convert(space).coords(nans=False) + l2, a2, b2 = sample.convert(space).coords(nans=False) # Equation (3) c1 = math.sqrt(a1 ** 2 + b1 ** 2) diff --git a/lib/coloraide/distance/delta_e_hct.py b/lib/coloraide/distance/delta_e_hct.py index bf2879a..4ce73c0 100644 --- a/lib/coloraide/distance/delta_e_hct.py +++ b/lib/coloraide/distance/delta_e_hct.py @@ -1,8 +1,9 @@ """Delta E CAM16.""" +from __future__ import annotations import math from ..distance import DeltaE from ..spaces.cam16_ucs import COEFFICENTS -from ..types import Vector +from ..types import VectorLike from typing import Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover @@ -11,15 +12,21 @@ COEFF2 = COEFFICENTS['ucs'][2] -def convert_ucs_ab(c: float, h: float) -> Vector: - """Convert HCT chroma and hue (CAM16 JMh colorfulness and hue) to UCS a and b.""" +def convert_ucs_ab(color: Color) -> VectorLike: + """Convert HCT chroma and hue (CAM16 JMh colorfulness and hue) using UCS logic for a and b.""" + env = color._space.ENV # type: ignore[attr-defined] + h, c, t = color.coords() + + # Only in extreme cases (far outside the visible spectrum) + # can the input value for log become negative. + # Avoid domain error by forcing zero. + M = math.log(max(1 + COEFF2 * c * env.fl_root, 1.0)) / COEFF2 hrad = math.radians(h) - M = math.log(1 + COEFF2 * c) / COEFF2 a = M * math.cos(hrad) b = M * math.sin(hrad) - return [a, b] + return t, a, b class DEHCT(DeltaE): @@ -27,14 +34,15 @@ class DEHCT(DeltaE): NAME = "hct" - def distance(self, color: 'Color', sample: 'Color', **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: """Delta E HCT color distance formula.""" - h1, c1, t1 = color.convert('hct', norm=False).coords(nans=False) - h2, c2, t2 = sample.convert('hct', norm=False).coords(nans=False) - - a1, b1 = convert_ucs_ab(c1, h1) - a2, b2 = convert_ucs_ab(c2, h2) + t1, a1, b1 = convert_ucs_ab( + color.convert('hct', norm=False) if color.space() != 'hct' else color.clone().normalize(nans=False) + ) + t2, a2, b2 = convert_ucs_ab( + sample.convert('hct', norm=False) if sample.space() != 'hct' else sample.clone().normalize(nans=False) + ) # Use simple euclidean distance return math.sqrt((t1 - t2) ** 2 + (a1 - a2) ** 2 + (b1 - b2) ** 2) diff --git a/lib/coloraide/distance/delta_e_hyab.py b/lib/coloraide/distance/delta_e_hyab.py index 5a7c440..551b0c0 100644 --- a/lib/coloraide/distance/delta_e_hyab.py +++ b/lib/coloraide/distance/delta_e_hyab.py @@ -1,8 +1,9 @@ """HyAB distance.""" +from __future__ import annotations from ..distance import DeltaE import math from ..spaces import Labish -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -18,7 +19,7 @@ def __init__(self, space: str = "lab-d65") -> None: self.space = space - def distance(self, color: 'Color', sample: 'Color', space: Optional[str] = None, **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, space: str | None = None, **kwargs: Any) -> float: """ HyAB distance for Lab-ish spaces. diff --git a/lib/coloraide/distance/delta_e_itp.py b/lib/coloraide/distance/delta_e_itp.py index 741d067..26c3f2b 100644 --- a/lib/coloraide/distance/delta_e_itp.py +++ b/lib/coloraide/distance/delta_e_itp.py @@ -3,9 +3,10 @@ https://kb.portrait.com/help/ictcp-color-difference-metric """ +from __future__ import annotations from ..distance import DeltaE import math -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -21,7 +22,7 @@ def __init__(self, scalar: float = 720) -> None: self.scalar = scalar - def distance(self, color: 'Color', sample: 'Color', scalar: Optional[float] = None, **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, scalar: float | None = None, **kwargs: Any) -> float: """Delta E ITP color distance formula.""" if scalar is None: diff --git a/lib/coloraide/distance/delta_e_ok.py b/lib/coloraide/distance/delta_e_ok.py index 9186d42..7166400 100644 --- a/lib/coloraide/distance/delta_e_ok.py +++ b/lib/coloraide/distance/delta_e_ok.py @@ -1,23 +1,23 @@ """Delta E OK.""" -from .delta_e_76 import DE76 -from typing import TYPE_CHECKING, Any, Optional +from __future__ import annotations +from ..distance import DeltaE, distance_euclidean +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color -class DEOK(DE76): - """Delta E OK class.""" +class DEOK(DeltaE): + """Delta E 99o class.""" - NAME = "ok" - SPACE = "oklab" + NAME = 'ok' def __init__(self, scalar: float = 1) -> None: """Initialize.""" self.scalar = scalar - def distance(self, color: 'Color', sample: 'Color', scalar: Optional[float] = None, **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, scalar: float | None = None, **kwargs: Any) -> float: """ Delta E OK color distance formula. @@ -27,5 +27,4 @@ def distance(self, color: 'Color', sample: 'Color', scalar: Optional[float] = No if scalar is None: scalar = self.scalar - # Equation (1) - return scalar * super().distance(color, sample) + return scalar * distance_euclidean(color, sample, space='oklab') diff --git a/lib/coloraide/distance/delta_e_z.py b/lib/coloraide/distance/delta_e_z.py index a83ba10..7f22404 100644 --- a/lib/coloraide/distance/delta_e_z.py +++ b/lib/coloraide/distance/delta_e_z.py @@ -3,6 +3,7 @@ https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272 """ +from __future__ import annotations from ..distance import DeltaE import math from typing import TYPE_CHECKING, Any @@ -16,7 +17,7 @@ class DEZ(DeltaE): NAME = "jz" - def distance(self, color: 'Color', sample: 'Color', **kwargs: Any) -> float: + def distance(self, color: Color, sample: Color, **kwargs: Any) -> float: """Delta E z color distance formula.""" jz1, az1, bz1 = color.convert('jzazbz').coords(nans=False) diff --git a/lib/coloraide/easing.py b/lib/coloraide/easing.py index 871f0e1..11c4775 100644 --- a/lib/coloraide/easing.py +++ b/lib/coloraide/easing.py @@ -38,9 +38,10 @@ This greatly simplifies things and makes it faster. """ +from __future__ import annotations import functools -from typing import Tuple, Callable -from . import algebra as alg +import math +from typing import Callable EPSILON = 1e-6 MAX_ITER = 8 @@ -74,7 +75,7 @@ def _solve_bezier(target: float, a: float, b: float, c: float) -> float: # Try Newtons method to see if we can find a suitable value x = 0.0 t = 0.5 - last = alg.nan + last = math.nan for _ in range(MAX_ITER): # See how close we are to the desired `x` x = _bezier(t, a, b, c) - target @@ -118,7 +119,7 @@ def _solve_bezier(target: float, a: float, b: float, c: float) -> float: return t # pragma: no cover -def _extrapolate(t: float, p1: Tuple[float, float], p2: Tuple[float, float]) -> float: +def _extrapolate(t: float, p1: tuple[float, float], p2: tuple[float, float]) -> float: """ Extrapolate. @@ -159,11 +160,11 @@ def _extrapolate(t: float, p1: Tuple[float, float], p2: Tuple[float, float]) -> def _calc_bezier( target: float, - a: Tuple[float, float], - b: Tuple[float, float], - c: Tuple[float, float], - p1: Tuple[float, float], - p2: Tuple[float, float] + a: tuple[float, float], + b: tuple[float, float], + c: tuple[float, float], + p1: tuple[float, float], + p2: tuple[float, float] ) -> float: """ Calculate the y value of the bezier curve with the given `x`. diff --git a/lib/coloraide/everything.py b/lib/coloraide/everything.py index 79310fd..4d45250 100644 --- a/lib/coloraide/everything.py +++ b/lib/coloraide/everything.py @@ -1,10 +1,6 @@ """Everything and the kitchen sink.""" +from __future__ import annotations from .color import Color as Base -from .spaces.rec2100_pq import Rec2100PQ -from .spaces.rec2100_hlg import Rec2100HLG -from .spaces.jzazbz import Jzazbz -from .spaces.jzczhz import JzCzhz -from .spaces.ictcp import ICtCp from .spaces.din99o import DIN99o from .spaces.lch99o import LCh99o from .spaces.luv import Luv @@ -28,17 +24,15 @@ from .spaces.acescg import ACEScg from .spaces.acescc import ACEScc from .spaces.acescct import ACEScct -from .spaces.cam16 import CAM16 from .spaces.cam16_jmh import CAM16JMh from .spaces.cam16_ucs import CAM16UCS, CAM16LCD, CAM16SCD +from .spaces.zcam_jmh import ZCAMJMh from .spaces.hct import HCT from .spaces.ucs import UCS from .spaces.rec709 import Rec709 from .spaces.ryb import RYB, RYBBiased from .spaces.cubehelix import Cubehelix -from .distance.delta_e_itp import DEITP from .distance.delta_e_99o import DE99o -from .distance.delta_e_z import DEZ from .distance.delta_e_cam16 import DECAM16 from .distance.delta_e_hct import DEHCT from .gamut.fit_hct_chroma import HCTChroma @@ -60,11 +54,6 @@ class ColorAll(Base): [ # Spaces Rec709(), - Rec2100PQ(), - Rec2100HLG(), - Jzazbz(), - JzCzhz(), - ICtCp(), DIN99o(), LCh99o(), Luv(), @@ -88,7 +77,6 @@ class ColorAll(Base): ACEScg(), ACEScc(), ACEScct(), - CAM16(), CAM16JMh(), CAM16UCS(), CAM16SCD(), @@ -98,11 +86,10 @@ class ColorAll(Base): RYB(), RYBBiased(), Cubehelix(), + ZCAMJMh(), # Delta E - DEITP(), DE99o(), - DEZ(), DECAM16(), DEHCT(), diff --git a/lib/coloraide/filters/__init__.py b/lib/coloraide/filters/__init__.py index 601583e..48397f1 100644 --- a/lib/coloraide/filters/__init__.py +++ b/lib/coloraide/filters/__init__.py @@ -1,7 +1,8 @@ """Provides a plugin system for filtering colors.""" +from __future__ import annotations from abc import ABCMeta, abstractmethod from ..types import Plugin -from typing import Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -12,22 +13,22 @@ class Filter(Plugin, metaclass=ABCMeta): NAME = '' DEFAULT_SPACE = 'srgb-linear' - ALLOWED_SPACES = ('srgb-linear',) # type: Tuple[str, ...] + ALLOWED_SPACES = ('srgb-linear',) # type: tuple[str, ...] @abstractmethod - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Filter the given color.""" def filters( - color: 'Color', + color: Color, name: str, - amount: Optional[float] = None, - space: Optional[str] = None, - out_space: Optional[str] = None, + amount: float | None = None, + space: str | None = None, + out_space: str | None = None, in_place: bool = False, **kwargs: Any -) -> 'Color': +) -> Color: """Filter.""" f = color.FILTER_MAP.get(name) diff --git a/lib/coloraide/filters/cvd.py b/lib/coloraide/filters/cvd.py index 3ea3655..abc8a55 100644 --- a/lib/coloraide/filters/cvd.py +++ b/lib/coloraide/filters/cvd.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- """Color vision deficiency.""" +from __future__ import annotations from .. import algebra as alg from ..filters import Filter from ..types import Vector, Matrix -from typing import Any, Optional, Dict, Tuple, Callable, TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -32,7 +33,7 @@ [0.0, -0.4913704825845725, 69.55210571887513] ], [0.0, 0.016813516536000002, -0.344781556122] -) # type: Tuple[Matrix, Matrix, Vector] +) # type: tuple[Matrix, Matrix, Vector] BRETTEL_DEUTAN = ( [ @@ -46,7 +47,7 @@ [-0.2250635463221503, 0.0, 68.24609126806706] ], [-0.016813516536000002, 0.0, 0.6551784438780001] -) # type: Tuple[Matrix, Matrix, Vector] +) # type: tuple[Matrix, Matrix, Vector] BRETTEL_TRITAN = ( [ @@ -60,7 +61,7 @@ [-0.2150297288038942, 3.3090019545637928, 0.0] ], [0.344781556122, -0.6551784438780001, 0.0] -) # type: Tuple[Matrix, Matrix, Vector] +) # type: tuple[Matrix, Matrix, Vector] VIENOT_PROTAN = [ [0.11238276122216405, 0.8876172387778362, 5.551115123125783e-17], @@ -92,7 +93,7 @@ 8: [[0.259411, 0.923008, -0.182420], [0.110296, 0.804340, 0.085364], [-0.006276, -0.034346, 1.040622]], 9: [[0.203876, 0.990338, -0.194214], [0.112975, 0.794542, 0.092483], [-0.005222, -0.041043, 1.046265]], 10: [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]] -} # type: Dict[int, Matrix] +} # type: dict[int, Matrix] MACHADO_DEUTAN = { 0: [[1.000000, 0.000000, -0.000000], [0.000000, 1.000000, 0.000000], [-0.000000, -0.000000, 1.000000]], @@ -107,7 +108,7 @@ 9: [[0.392952, 0.823610, -0.216562], [0.263559, 0.690210, 0.046232], [-0.011910, 0.040281, 0.971630]], 10: [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.011820, 0.042940, 0.968881]], -} # type: Dict[int, Matrix] +} # type: dict[int, Matrix] MACHADO_TRITAN = { 0: [[1.000000, 0.000000, -0.000000], [0.000000, 1.000000, 0.000000], [-0.000000, -0.000000, 1.000000]], @@ -121,10 +122,10 @@ 8: [[1.257728, -0.139648, -0.118081], [-0.078003, 0.975409, 0.102594], [-0.003316, 0.501214, 0.502102]], 9: [[1.278864, -0.125333, -0.153531], [-0.084748, 0.957674, 0.127074], [-0.000989, 0.601151, 0.399838]], 10: [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.303900]], -} # type: Dict[int, Matrix] +} # type: dict[int, Matrix] -def brettel(color: 'Color', severity: float, wings: Tuple[Matrix, Matrix, Vector]) -> None: +def brettel(color: Color, severity: float, wings: tuple[Matrix, Matrix, Vector]) -> None: """ Calculate color blindness using Brettel 1997. @@ -136,11 +137,11 @@ def brettel(color: 'Color', severity: float, wings: Tuple[Matrix, Matrix, Vector w1, w2, sep = wings # Convert to LMS - lms_c = alg.dot(LRGB_TO_LMS, color[:-1], dims=alg.D2_D1) + lms_c = alg.matmul(LRGB_TO_LMS, color[:-1], dims=alg.D2_D1) # Apply appropriate wing filter based on which side of the separator we are on. # Tritanopia filter and LMS to sRGB conversion are included in the same matrix. - coords = alg.dot(w2 if alg.dot(lms_c, sep) > 0 else w1, lms_c, dims=alg.D2_D1) + coords = alg.matmul(w2 if alg.matmul(lms_c, sep) > 0 else w1, lms_c, dims=alg.D2_D1) if severity < 1: color[:-1] = [alg.lerp(a, b, severity) for a, b in zip(color[:-1], coords)] @@ -148,7 +149,7 @@ def brettel(color: 'Color', severity: float, wings: Tuple[Matrix, Matrix, Vector color[:-1] = coords -def vienot(color: 'Color', severity: float, transform: Matrix) -> None: +def vienot(color: Color, severity: float, transform: Matrix) -> None: """ Calculate color blindness using the Viénot, Brettel, and Mollon 1999 approach, best for protanopia and deuteranopia. @@ -164,14 +165,14 @@ def vienot(color: 'Color', severity: float, transform: Matrix) -> None: then we interpolate against the original color. """ - coords = alg.dot(transform, color[:-1], dims=alg.D2_D1) + coords = alg.matmul(transform, color[:-1], dims=alg.D2_D1) if severity < 1: color[:-1] = [alg.lerp(c1, c2, severity) for c1, c2 in zip(color[:-1], coords)] else: color[:-1] = coords -def machado(color: 'Color', severity: float, matrices: Dict[int, Matrix]) -> None: +def machado(color: Color, severity: float, matrices: dict[int, Matrix]) -> None: """ Machado approach to protanopia, deuteranopia, and tritanopia. @@ -187,7 +188,7 @@ def machado(color: 'Color', severity: float, matrices: Dict[int, Matrix]) -> Non # Filter the color according to the severity m1 = matrices[severity1] - coords = alg.dot(m1, color[:-1], dims=alg.D2_D1) + coords = alg.matmul(m1, color[:-1], dims=alg.D2_D1) # If severity was not exact, and it also isn't max severity, # let's calculate the next most severity and interpolate @@ -204,7 +205,7 @@ def machado(color: 'Color', severity: float, matrices: Dict[int, Matrix]) -> Non # but it ends up being faster just modifying the color on both the high # and low matrix and interpolating the color than interpolating the matrix # and then applying it to the color. The results are identical as well. - coords2 = alg.dot(m2, color[:-1], dims=alg.D2_D1) + coords2 = alg.matmul(m2, color[:-1], dims=alg.D2_D1) coords = [alg.lerp(c1, c2, weight) for c1, c2 in zip(coords, coords2)] # Return the altered color @@ -222,23 +223,23 @@ class Protan(Filter): VIENOT = VIENOT_PROTAN MACHADO = MACHADO_PROTAN - def __init__(self, severe: str = 'vienot', anomalous: str = 'machado') -> None: + def __init__(self, severe: str = 'vienot', anomalous: str = 'machado', **kwargs: Any) -> None: """Initialize.""" self.severe = severe self.anomalous = anomalous - def brettel(self, color: 'Color', severity: float) -> None: + def brettel(self, color: Color, severity: float) -> None: """Tritanopia vision deficiency using Brettel method.""" brettel(color, severity, self.BRETTEL) - def vienot(self, color: 'Color', severity: float) -> None: + def vienot(self, color: Color, severity: float) -> None: """Tritanopia vision deficiency using Viénot method.""" vienot(color, severity, self.VIENOT) - def machado(self, color: 'Color', severity: float) -> None: + def machado(self, color: Color, severity: float) -> None: """Tritanopia vision deficiency using Machado method.""" machado(color, severity, self.MACHADO) @@ -255,17 +256,17 @@ def select_filter(self, method: str) -> Callable[..., None]: else: raise ValueError("Unrecognized CVD filter method '{}'".format(method)) - def get_best_filter(self, method: Optional[str], max_severity: bool) -> Callable[..., None]: + def get_best_filter(self, method: str | None, max_severity: bool) -> Callable[..., None]: """Get the best filter based on the situation.""" if method is None: method = self.severe if max_severity else self.anomalous return self.select_filter(method) - def filter(self, color: 'Color', amount: Optional[float] = None, **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None = None, **kwargs: Any) -> None: # noqa: A003 """Filter the color.""" - method = kwargs.get('method') # type: Optional[str] + method = kwargs.get('method') # type: str | None amount = alg.clamp(1 if amount is None else amount, 0, 1) self.get_best_filter(method, amount == 1)(color, amount) diff --git a/lib/coloraide/filters/w3c_filter_effects.py b/lib/coloraide/filters/w3c_filter_effects.py index 927db03..8adc4de 100644 --- a/lib/coloraide/filters/w3c_filter_effects.py +++ b/lib/coloraide/filters/w3c_filter_effects.py @@ -1,8 +1,9 @@ """Provide filters as described by the https://www.w3.org/TR/filter-effects-1/.""" +from __future__ import annotations import math from ..filters import Filter from .. import algebra as alg -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -24,7 +25,7 @@ class Sepia(Filter): NAME = 'sepia' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a sepia filter to the color.""" amount = 1 - alg.clamp(1 if amount is None else amount, 0, 1) @@ -35,7 +36,7 @@ def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None [0.272 - 0.272 * amount, 0.534 - 0.534 * amount, 0.131 + 0.869 * amount] ] - color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) class Grayscale(Filter): @@ -44,7 +45,7 @@ class Grayscale(Filter): NAME = 'grayscale' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a grayscale filter to the color.""" amount = 1 - alg.clamp(1 if amount is None else amount, 0, 1) @@ -55,7 +56,7 @@ def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None [0.2126 - 0.2126 * amount, 0.7152 - 0.7152 * amount, 0.0722 + 0.9278 * amount] ] - color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) class Saturate(Filter): @@ -64,7 +65,7 @@ class Saturate(Filter): NAME = 'saturate' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a saturation filter to the color.""" amount = alg.clamp(1 if amount is None else amount, 0) @@ -75,16 +76,16 @@ def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None [0.213 - 0.213 * amount, 0.715 - 0.715 * amount, 0.072 + 0.928 * amount] ] - color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) class Invert(Filter): - """Grayscale filter.""" + """Invert filter.""" NAME = 'invert' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply an invert filter.""" amount = alg.clamp(1 if amount is None else amount, 0, 1) @@ -98,7 +99,7 @@ class Opacity(Filter): NAME = 'opacity' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply an opacity filter.""" amount = alg.clamp(1 if amount is None else amount, 0, 1) @@ -111,7 +112,7 @@ class Brightness(Filter): NAME = 'brightness' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a brightness filter.""" amount = alg.clamp(1 if amount is None else amount, 0) @@ -125,7 +126,7 @@ class Contrast(Filter): NAME = 'contrast' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a contrast filter.""" amount = alg.clamp(1 if amount is None else amount, 0) @@ -139,7 +140,7 @@ class HueRotate(Filter): NAME = 'hue-rotate' ALLOWED_SPACES = ('srgb-linear', 'srgb') - def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + def filter(self, color: Color, amount: float | None, **kwargs: Any) -> None: # noqa: A003 """Apply a hue rotation filter.""" rad = math.radians(0 if amount is None else amount) @@ -152,4 +153,4 @@ def filter(self, color: 'Color', amount: Optional[float], **kwargs: Any) -> None [0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072] ] - color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + color[:-1] = alg.matmul(m, color[:-1], dims=alg.D2_D1) diff --git a/lib/coloraide/gamut/__init__.py b/lib/coloraide/gamut/__init__.py index 353b204..270766c 100644 --- a/lib/coloraide/gamut/__init__.py +++ b/lib/coloraide/gamut/__init__.py @@ -1,11 +1,10 @@ """Gamut handling.""" +from __future__ import annotations import math -from .. import algebra as alg from ..channels import FLG_ANGLE from abc import ABCMeta, abstractmethod from ..types import Plugin from typing import TYPE_CHECKING, Any -from .. import util from . import pointer if TYPE_CHECKING: # pragma: no cover @@ -13,34 +12,45 @@ __all__ = ('clip_channels', 'verify', 'Fit', 'pointer') -def clip_channels(color: 'Color', nans: bool = True) -> None: + +def clip_channels(color: Color, nans: bool = True) -> bool: """Clip channels.""" - for i, value in enumerate(color[:-1]): + clipped = False - chan = color._space.CHANNELS[i] + cs = color._space + for i, value in enumerate(cs.normalize(color[:-1])): - # Wrap the angle. Not technically out of gamut, but we will clean it up. - if chan.flags & FLG_ANGLE: - color[i] = util.constrain_hue(value) - continue + chan = cs.CHANNELS[i] - # Ignore undefined or unbounded channels - if not chan.bound or math.isnan(value): + # Ignore angles, undefined, or unbounded channels + if not chan.bound or math.isnan(value) or chan.flags & FLG_ANGLE: + color[i] = value continue # Fit value in bounds. - color[i] = alg.clamp(value, chan.low, chan.high) + if value < chan.low: + color[i] = chan.low + elif value > chan.high: + color[i] = chan.high + else: + color[i] = value + continue + + clipped = True + + return clipped -def verify(color: 'Color', tolerance: float) -> bool: +def verify(color: Color, tolerance: float) -> bool: """Verify the values are in bound.""" - for i, value in enumerate(color[:-1]): - chan = color._space.CHANNELS[i] + cs = color._space + for i, value in enumerate(cs.normalize(color[:-1])): + chan = cs.CHANNELS[i] # Ignore undefined channels, angles which wrap, and unbounded channels - if chan.flags & FLG_ANGLE or not chan.bound or math.isnan(value): + if not chan.bound or math.isnan(value) or chan.flags & FLG_ANGLE: continue a = chan.low @@ -58,5 +68,5 @@ class Fit(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def fit(self, color: 'Color', **kwargs: Any) -> None: + def fit(self, color: Color, space: str, **kwargs: Any) -> None: """Get coordinates of the new gamut mapped color.""" diff --git a/lib/coloraide/gamut/fit_hct_chroma.py b/lib/coloraide/gamut/fit_hct_chroma.py index b247082..196531b 100644 --- a/lib/coloraide/gamut/fit_hct_chroma.py +++ b/lib/coloraide/gamut/fit_hct_chroma.py @@ -1,4 +1,5 @@ """HCT gamut mapping.""" +from __future__ import annotations from ..gamut.fit_lch_chroma import LChChroma @@ -7,9 +8,10 @@ class HCTChroma(LChChroma): NAME = "hct-chroma" - EPSILON = 0.001 - LIMIT = 0.02 + EPSILON = 0.01 + LIMIT = 2.0 DE = "hct" + DE_OPTIONS = {} SPACE = "hct" MIN_LIGHTNESS = 0 MAX_LIGHTNESS = 100 diff --git a/lib/coloraide/gamut/fit_lch_chroma.py b/lib/coloraide/gamut/fit_lch_chroma.py index b19d37e..e1a75f4 100644 --- a/lib/coloraide/gamut/fit_lch_chroma.py +++ b/lib/coloraide/gamut/fit_lch_chroma.py @@ -1,10 +1,12 @@ """Fit by compressing chroma in LCh.""" +from __future__ import annotations +import functools from ..gamut import Fit, clip_channels from ..cat import WHITES from .. import util import math from .. import algebra as alg -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -13,6 +15,13 @@ BLACK = [0, 0, 0] +@functools.lru_cache(maxsize=10) +def calc_epsilon(jnd: float) -> float: + """Calculate the epsilon to 2 degrees smaller than the specified JND.""" + + return float("1e{:d}".format(alg.order(jnd) - 2)) + + class LChChroma(Fit): """ LCh chroma gamut mapping class. @@ -31,30 +40,33 @@ class LChChroma(Fit): NAME = "lch-chroma" - EPSILON = 0.1 + EPSILON = 0.01 LIMIT = 2.0 DE = "2000" - DE_OPTIONS = {} # type: Dict[str, Any] + DE_OPTIONS = {'space': 'lab-d65'} # type: dict[str, Any] SPACE = "lch-d65" MIN_LIGHTNESS = 0 MAX_LIGHTNESS = 100 MIN_CONVERGENCE = 0.0001 - def fit(self, color: 'Color', **kwargs: Any) -> None: + def fit(self, color: Color, space: str, *, jnd: float | None = None, **kwargs: Any) -> None: """Gamut mapping via CIELCh chroma.""" - space = color.space() - mapcolor = color.convert(self.SPACE, norm=False) + orig = color.space() + mapcolor = color.convert(self.SPACE, norm=False) if orig != self.SPACE else color.clone().normalize(nans=False) + gamutcolor = color.convert(space, norm=False) if orig != space else color.clone().normalize(nans=False) l, c = mapcolor._space.indexes()[:2] # type: ignore[attr-defined] lightness = mapcolor[l] - sdr = color._space.DYNAMIC_RANGE == 'sdr' + sdr = gamutcolor._space.DYNAMIC_RANGE == 'sdr' + if jnd is None: + jnd = self.LIMIT + epsilon = self.EPSILON + else: + epsilon = calc_epsilon(jnd) # Return white or black if lightness is out of dynamic range for lightness. # Extreme light case only applies to SDR, but dark case applies to all ranges. - if ( - sdr and - (lightness >= self.MAX_LIGHTNESS or alg.isclose(lightness, self.MAX_LIGHTNESS, abs_tol=1e-6, dims=alg.SC)) - ): + if sdr and (lightness >= self.MAX_LIGHTNESS or math.isclose(lightness, self.MAX_LIGHTNESS, abs_tol=1e-6)): clip_channels(color.update('xyz-d65', WHITE, mapcolor[-1])) return elif lightness <= self.MIN_LIGHTNESS: @@ -64,10 +76,10 @@ def fit(self, color: 'Color', **kwargs: Any) -> None: # Set initial chroma boundaries low = 0.0 high = mapcolor[c] - clip_channels(color._hotswap(mapcolor.convert(space, norm=False))) + clip_channels(gamutcolor._hotswap(mapcolor.convert(space, norm=False))) # Adjust chroma if we are not under the JND yet. - if mapcolor.delta_e(color, method=self.DE, **self.DE_OPTIONS) >= self.LIMIT: + if mapcolor.delta_e(gamutcolor, method=self.DE, **self.DE_OPTIONS) >= jnd: # Perform "in gamut" checks until we know our lower bound is no longer in gamut. lower_in_gamut = True @@ -80,12 +92,12 @@ def fit(self, color: 'Color', **kwargs: Any) -> None: if lower_in_gamut and mapcolor.in_gamut(space, tolerance=0): low = mapcolor[c] else: - clip_channels(color._hotswap(mapcolor.convert(space, norm=False))) - de = mapcolor.delta_e(color, method=self.DE, **self.DE_OPTIONS) - if de < self.LIMIT: + clip_channels(gamutcolor._hotswap(mapcolor.convert(space, norm=False))) + de = mapcolor.delta_e(gamutcolor, method=self.DE, **self.DE_OPTIONS) + if de < jnd: # Kick out as soon as we are close enough to the JND. # Too far below and we may reduce chroma too aggressively. - if (self.LIMIT - de) < self.EPSILON: + if (jnd - de) < epsilon: break # Our lower bound is now out of gamut, so all future searches are @@ -97,4 +109,4 @@ def fit(self, color: 'Color', **kwargs: Any) -> None: else: # We are still outside the gamut and outside the JND high = mapcolor[c] - color.normalize() + color._hotswap(gamutcolor.convert(orig, norm=False)).normalize() diff --git a/lib/coloraide/gamut/fit_lch_raytrace.py b/lib/coloraide/gamut/fit_lch_raytrace.py new file mode 100644 index 0000000..2866300 --- /dev/null +++ b/lib/coloraide/gamut/fit_lch_raytrace.py @@ -0,0 +1,9 @@ +"""Gamut map using ray tracing.""" +from .fit_raytrace import RayTrace + + +class LChRayTrace(RayTrace): + """Apply gamut mapping using ray tracing.""" + + NAME = 'lch-raytrace' + PSPACE = "lch-d65" diff --git a/lib/coloraide/gamut/fit_oklch_chroma.py b/lib/coloraide/gamut/fit_oklch_chroma.py index 8f17649..be3c0ec 100644 --- a/lib/coloraide/gamut/fit_oklch_chroma.py +++ b/lib/coloraide/gamut/fit_oklch_chroma.py @@ -1,4 +1,5 @@ """Fit by compressing chroma in OkLCh.""" +from __future__ import annotations from .fit_lch_chroma import LChChroma @@ -10,5 +11,6 @@ class OkLChChroma(LChChroma): EPSILON = 0.0001 LIMIT = 0.02 DE = "ok" + DE_OPTIONS = {} SPACE = "oklch" MAX_LIGHTNESS = 1 diff --git a/lib/coloraide/gamut/fit_oklch_raytrace.py b/lib/coloraide/gamut/fit_oklch_raytrace.py new file mode 100644 index 0000000..8575af1 --- /dev/null +++ b/lib/coloraide/gamut/fit_oklch_raytrace.py @@ -0,0 +1,9 @@ +"""Gamut map using ray tracing.""" +from .fit_raytrace import RayTrace + + +class OkLChRayTrace(RayTrace): + """Apply gamut mapping using ray tracing.""" + + NAME = 'oklch-raytrace' + PSPACE = "oklch" diff --git a/lib/coloraide/gamut/fit_raytrace.py b/lib/coloraide/gamut/fit_raytrace.py new file mode 100644 index 0000000..a556f25 --- /dev/null +++ b/lib/coloraide/gamut/fit_raytrace.py @@ -0,0 +1,269 @@ +""" +Gamut mapping by using ray tracing. + +This employs a faster approach than bisecting to reduce chroma. +""" +from __future__ import annotations +import math +from .. import algebra as alg +from ..gamut import Fit +from ..spaces import Space, RGBish, HSLish, HSVish, HWBish, Labish +from ..spaces.hsl import hsl_to_srgb, srgb_to_hsl +from ..spaces.hsv import hsv_to_srgb, srgb_to_hsv +from ..spaces.hwb import hwb_to_srgb, srgb_to_hwb +from ..spaces.srgb_linear import sRGBLinear +from ..deprecate import warn_deprecated +from ..types import Vector, VectorLike +from typing import TYPE_CHECKING, Callable, Any # noqa: F401 + +if TYPE_CHECKING: # pragma: no cover + from ..color import Color + + +def coerce_to_rgb(OrigColor: type[Color], cs: Space) -> tuple[type[Color], str]: + """ + Coerce an HSL, HSV, or HWB color space to RGB to allow us to ray trace the gamut. + + It is rare to have a color space that is bound to an RGB gamut that does not exist as an RGB + defined RGB space. HPLuv is one that is defined only as a cylindrical, HSL-like space. Okhsl + and Okhsv are another whose gamut is meant to target sRGB, but it is very fuzzy and has sRGB + colors not quite in gamut, and others that exceed the sRGB gamut. + + For gamut mapping, RGB cylindrical spaces can be coerced into an RGB form using traditional + HSL, HSV, or HWB approaches which is good enough. + """ + + if isinstance(cs, HSLish): + to_ = hsl_to_srgb # type: Callable[[Vector], Vector] + from_ = srgb_to_hsl # type: Callable[[Vector], Vector] + elif isinstance(cs, HSVish): + to_ = hsv_to_srgb + from_ = srgb_to_hsv + elif isinstance(cs, HWBish): # pragma: no cover + to_ = hwb_to_srgb + from_ = srgb_to_hwb + else: # pragma: no cover + raise ValueError('Cannot coerce {} to an RGB space.'.format(cs.NAME)) + + class RGB(sRGBLinear): + """Custom RGB class.""" + + NAME = '-rgb-{}'.format(cs.NAME) + BASE = cs.NAME + GAMUT_CHECK = None + CLIP_SPACE = None + WHITE = cs.WHITE + DYAMIC_RANGE = cs.DYNAMIC_RANGE + INDEXES = cs.indexes() # type: ignore[attr-defined] + # Scale saturation and lightness (or HWB whiteness and blackness) + SCALE_SAT = cs.CHANNELS[INDEXES[1]].high + SCALE_LIGHT = cs.CHANNELS[INDEXES[1]].high + + def to_base(self, coords: Vector) -> Vector: + """Convert from RGB to HSL.""" + + coords = from_(coords) + if self.SCALE_SAT != 1: + coords[1] *= self.SCALE_SAT + if self.SCALE_LIGHT != 1: + coords[2] *= self.SCALE_LIGHT + ordered = [0.0, 0.0, 0.0] + for e, c in enumerate(coords): + ordered[self.INDEXES[e]] = c + return ordered + + def from_base(self, coords: Vector) -> Vector: + """Convert from HSL to RGB.""" + + coords = [coords[i] for i in self.INDEXES] + if self.SCALE_SAT != 1: + coords[1] /= self.SCALE_SAT + if self.SCALE_LIGHT != 1: + coords[2] /= self.SCALE_LIGHT + coords = to_(coords) + return coords + + class ColorRGB(OrigColor): # type: ignore[valid-type, misc] + """Custom color.""" + + ColorRGB.register(RGB()) + + return ColorRGB, RGB.NAME + + +def raytrace_box( + start: Vector, + end: Vector, + bmin: VectorLike = (0.0, 0.0, 0,0), + bmax: VectorLike = (1.0, 1.0, 1.0) +) -> Vector: + """ + Return the intersection of an axis aligned box using slab method. + + https://en.wikipedia.org/wiki/Slab_method + """ + + tfar = math.inf + tnear = -math.inf + direction = [] + for i in range(3): + a = start[i] + b = end[i] + d = b - a + direction.append(d) + bn = bmin[i] + bx = bmax[i] + + # Non parallel case + if d: + inv_d = 1 / d + t1 = (bn - a) * inv_d + t2 = (bx - a) * inv_d + tnear = max(min(t1, t2), tnear) + tfar = min(max(t1, t2), tfar) + + # Parallel case outside + elif a < bn or a > bx: + return [] + + # No hit + if tnear > tfar or tfar < 0: + return [] + + # Favor the intersection first in the direction start -> end + if tnear < 0: + tnear = tfar + + # An infinitesimally small point was used, not a ray. + # The origin is the intersection. Our use case will + # discard such scenarios, but others may wish to set + # intersection to origin. + if math.isinf(tnear): + return [] + + # Calculate intersection interpolation. + return [ + start[0] + direction[0] * tnear, + start[1] + direction[1] * tnear, + start[2] + direction[2] * tnear + ] + + +class RayTrace(Fit): + """Gamut mapping by using ray tracing.""" + + NAME = "raytrace" + PSPACE = "lch-d65" + + def fit( + self, + color: Color, + space: str, + *, + pspace: str | None = None, + lch: str | None = None, + **kwargs: Any + ) -> None: + """Scale the color within its gamut but preserve L and h as much as possible.""" + + is_lab = False + if lch is not None and pspace is None: # pragma: no cover + pspace = lch + warn_deprecated( + "'lch' parameter has been deprecated, please use 'pspace' to specify the perceptual space." + ) + elif pspace is None: + pspace = self.PSPACE + is_lab = isinstance(color.CS_MAP[pspace], Labish) + + cs = color.CS_MAP[space] + bmax = [1.0, 1.0, 1.0] + + # Requires an RGB-ish space, preferably a linear space. + # Coerce RGB cylinders with no defined RGB space to RGB + coerced = None + if not isinstance(cs, RGBish): + coerced = color + Color_, space = coerce_to_rgb(type(color), cs) + cs = Color_.CS_MAP[space] + color = Color_(color) + + # If there is a linear version of the RGB space, results will be + # better if we use that. If the target RGB space is HDR, we need to + # calculate the bounding box size based on the HDR limit in the linear space. + sdr = cs.DYNAMIC_RANGE != 'hdr' + linear = cs.linear() # type: ignore[attr-defined] + if linear and linear in color.CS_MAP: + if not sdr: + bmax = color.new(space, [chan.high for chan in cs.CHANNELS]).convert(linear)[:-1] + space = linear + + orig = color.space() + mapcolor = color.convert(pspace, norm=False) if orig != pspace else color.clone().normalize(nans=False) + achroma = mapcolor.clone() + + # Different perceptual spaces may have components in different orders, account for this + if is_lab: + l, a, b = mapcolor._space.indexes() # type: ignore[attr-defined] + light = mapcolor[l] + hue = alg.rect_to_polar(mapcolor[a], mapcolor[b])[1] + achroma[a] = 0 + achroma[b] = 0 + else: + l, c, h = achroma._space.indexes() # type: ignore[attr-defined] + light = mapcolor[l] + hue = mapcolor[h] + achroma[c] = 0 + + # Floating point math can cause some deviations between the max and min + # value in the achromatic RGB color. This is usually not an issue, but + # some perceptual spaces, such as CAM16 or HCT, may compensate for adapting + # luminance which may give an achromatic that is not quite achromatic, + # causing a more sizeable delta between the max and min value in the + # achromatic RGB color. To compensate for such deviations, take the + # average value of the RGB components and use that as the achromatic point. + # When dealing with simple floating point deviations, little to no change + # is observed, but for spaces like CAM16 or HCT, this can provide more + # reasonable gamut mapping. + achromatic = [sum(achroma.convert(space)[:-1]) / 3] * 3 + + # Return white or black if the achromatic version is not within the RGB cube. + # HDR colors currently use the RGB maximum lightness. We do not currently + # clip HDR colors to SDR white, but that could be done if required. + bmx = bmax[0] + point = achromatic[0] + if point >= bmx: + color.update(space, bmax, mapcolor[-1]) + elif point <= 0: + color.update(space, [0.0, 0.0, 0.0], mapcolor[-1]) + else: + # Create a ray from our current color to the color with zero chroma. + # Trace the line to the RGB cube finding the intersection. + # In between iterations, correct the L and H and then cast a ray + # to the new corrected color finding the intersection again. + mapcolor.convert(space, in_place=True) + for i in range(4): + if i: + mapcolor.convert(pspace, in_place=True) + if is_lab: + chroma = alg.rect_to_polar(mapcolor[a], mapcolor[b])[0] + ab = alg.polar_to_rect(chroma, hue) + mapcolor[l] = light + mapcolor[a] = ab[0] + mapcolor[b] = ab[1] + else: + mapcolor[l] = light + mapcolor[h] = hue + mapcolor.convert(space, in_place=True) + intersection = raytrace_box(achromatic, mapcolor[:-1], bmax=bmax) + if intersection: + mapcolor[:-1] = intersection + continue + break # pragma: no cover + + # Remove noise from floating point conversion. + color.update(space, [alg.clamp(x, 0.0, bmx) for x in mapcolor[:-1]], mapcolor[-1]) + + # If we have coerced a space to RGB, update the original + if coerced: + coerced.update(color) diff --git a/lib/coloraide/gamut/pointer.py b/lib/coloraide/gamut/pointer.py index 9cb2717..3f129af 100644 --- a/lib/coloraide/gamut/pointer.py +++ b/lib/coloraide/gamut/pointer.py @@ -3,14 +3,15 @@ Data used for the gamut: https://www.rit-mcsl.org/UsefulData/PointerData.xls. """ +from __future__ import annotations import math import bisect from ..spaces.lab import xyz_to_lab, lab_to_xyz from ..spaces.lch import lab_to_lch, lch_to_lab from .. import algebra as alg from .. import util -from ..types import Vector -from typing import TYPE_CHECKING, Tuple, List, Optional +from ..types import Vector, Matrix +from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -68,7 +69,7 @@ def lch_sc_to_xyY(lch: Vector) -> Vector: return util.xyz_to_xyY(lab_to_xyz(lch_to_lab(lch), XYZ_W), XYZ_W) -def to_lch_sc(color: 'Color') -> Vector: +def to_lch_sc(color: Color) -> Vector: """Convert a color to LCh with an SC illuminant.""" xyz = color.convert('xyz-d65').normalize(nans=False) @@ -76,7 +77,7 @@ def to_lch_sc(color: 'Color') -> Vector: return lab_to_lch(xyz_to_lab(xyz_sc, util.xy_to_xyz(WHITE_POINT_SC))) -def from_lch_sc(color: 'Color', lch: Vector) -> 'Color': +def from_lch_sc(color: Color, lch: Vector) -> Color: """Convert a color from LCh with an SC illuminant.""" xyz_sc = lab_to_xyz(lch_to_lab(lch), util.xy_to_xyz(WHITE_POINT_SC)) @@ -84,7 +85,7 @@ def from_lch_sc(color: 'Color', lch: Vector) -> 'Color': return color.update('xyz-d65', xyz_d65, color[-1]) -def closest_lightness(l: float) -> Tuple[int, float]: +def closest_lightness(l: float) -> tuple[int, float]: """Calculate the two closest lightness values and return the first index and interpolation factor.""" # Handle too low lightness inside tolerance @@ -106,7 +107,7 @@ def closest_lightness(l: float) -> Tuple[int, float]: return li, lf -def closest_hue(h: float) -> Tuple[int, float]: +def closest_hue(h: float) -> tuple[int, float]: """Calculate the two closest hues and return the first index and interpolation factor.""" hi = 0 @@ -141,7 +142,7 @@ def get_chroma_limit(l: float, h: float) -> float: return alg.lerp(alg.lerp(row1[li], row1[li + 1], lf), alg.lerp(row2[li], row2[li + 1], lf), hf) -def fit_pointer_gamut(color: 'Color') -> 'Color': +def fit_pointer_gamut(color: Color) -> Color: """Fit a color to the Pointer gamut.""" # Convert to CIE LCh with the SC illuminant @@ -159,7 +160,7 @@ def fit_pointer_gamut(color: 'Color') -> 'Color': return from_lch_sc(color, [new_l, new_c, h]) if adjusted else color -def in_pointer_gamut(color: 'Color', tolerance: float) -> bool: +def in_pointer_gamut(color: Color, tolerance: float) -> bool: """ See if color is within the pointer gamut. @@ -180,7 +181,7 @@ def in_pointer_gamut(color: 'Color', tolerance: float) -> bool: return c <= (get_chroma_limit(l, h) + tolerance) -def pointer_gamut_boundary(lightness: Optional[float] = None) -> List[Vector]: +def pointer_gamut_boundary(lightness: float | None = None) -> Matrix: """ Calculate the Pointer gamut boundary points for the given lightness. @@ -191,7 +192,7 @@ def pointer_gamut_boundary(lightness: Optional[float] = None) -> List[Vector]: # Maximum Pointer gamut boundary # For each hue, find the lightness/chroma point that is furthest away from the white point. if lightness is None: - max_gamut = [] # type: list[Vector] + max_gamut = [] # type: Matrix for i, h in enumerate(LCH_H): max_dxy = 0.0 max_xyy = [0.0, 0.0, 0.0] diff --git a/lib/coloraide/harmonies.py b/lib/coloraide/harmonies.py index 070fcfd..4a2798f 100644 --- a/lib/coloraide/harmonies.py +++ b/lib/coloraide/harmonies.py @@ -1,4 +1,6 @@ """Color harmonies.""" +from __future__ import annotations +import math from abc import ABCMeta, abstractmethod from . import algebra as alg from .spaces import Cylindrical, Labish, Regular, Space # noqa: F401 @@ -7,7 +9,7 @@ from .cat import WHITES from . import util from .types import Vector -from typing import TYPE_CHECKING, Optional, List, Dict, Any # noqa: F401 +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from .color import Color @@ -64,10 +66,10 @@ class Harmony(metaclass=ABCMeta): """Color harmony.""" @abstractmethod - def harmonize(self, color: 'Color', space: Optional[str]) -> List['Color']: + def harmonize(self, color: Color, space: str) -> list[Color]: """Get color harmonies.""" - def get_cylinder(self, color: 'Color', space: str) -> 'Color': + def get_cylinder(self, color: Color, space: str) -> Color: """Create a cylinder from a select number of color spaces on the fly.""" color = color.convert(space, norm=False).normalize() @@ -86,6 +88,12 @@ class HarmonyLCh(_HarmonyLCh): WHITE = cs.WHITE DYAMIC_RANGE = cs.DYNAMIC_RANGE INDEXES = cs.indexes() # type: ignore[attr-defined] + ORIG_SPACE = cs + + def is_achromatic(self, coords: Vector) -> bool | None: + """Check if space is achromatic.""" + + return self.ORIG_SPACE.is_achromatic(self.to_base(coords)) class ColorCyl(type(color)): # type: ignore[misc] """Custom color.""" @@ -104,9 +112,16 @@ class HarmonyHSL(_HarmonyHSL, HSL): SERIALIZE = ('---harmoncy-cylinder',) BASE = name GAMUT_CHECK = name + CLIP_SPACE = None WHITE = cs.WHITE DYAMIC_RANGE = cs.DYNAMIC_RANGE INDEXES = cs.indexes() if hasattr(cs, 'indexes') else [0, 1, 2] + ORIG_SPACE = cs + + def is_achromatic(self, coords: Vector) -> bool | None: + """Check if space is achromatic.""" + + return self.ORIG_SPACE.is_achromatic(self.to_base(coords)) class ColorCyl(type(color)): # type: ignore[no-redef, misc] """Custom color.""" @@ -122,32 +137,23 @@ class Monochromatic(Harmony): """ Monochromatic harmony. - Take a given color and create both tints and shades such that we have `RANGE` total steps ranging - from black -> color -> white. Normally, we will throw away pure black, pure white, and the duplicate - target color (as we interpolate with it on both sides) leaving us with RANGE - 3 colors to extract - the target `STEPS` from. The one exception is when targeting an achromatic color, and in that case, - we only throw away the duplicate color (though if a color is close enough to white or black, white - or black may not be included simply because we cannot get a reasonable step that includes it). - - Once we have our `RANGE`, we can extract a total of `STEPS` colors with the target color at the center - (when possible). If the target color is too close to the either the minimum or maximum color step, - there may not be enough tints or shades on one side, so the result may have to draw heavier on the - side that has more plentiful tints or shades which would cause the target color to shift from the center. - - The current `RANGE` was chosen as 12 as it seems to to provide OK contrast in most cases for the monochromatic - colors. The one exception is with a target color of black or very near black which may return at least one color - with very low contrast to black. Generally, extremely dark colors do not make a good target for color harmonies, - but it should be noted that OkLCh's lightness tends to the more darker side. The poor contrast may be - less with other color spaces. + Take a given color and create both tints and shades from black -> color -> white. + With a default count of 5, the goal is to generate 2 shades and 2 tints on either + side of the seed color, assuming a perfectly centered tone in the middle. If the color + is closer to black, more tints will be returned than shades and vice versa. + + If an achromatic color is specified as the input, black and white can be returned, otherwise, + black and white is usually not returned to only return non-achromatic palettes. """ DELTA_E = '2000' - RANGE = 12 - STEPS = 5 - def harmonize(self, color: 'Color', space: Optional[str]) -> List['Color']: + def harmonize(self, color: Color, space: str, count: int = 5) -> list[Color]: """Get color harmonies.""" + if count < 1: + raise ValueError('Cannot generate a monochromatic palette of {} colors.'.format(count)) + # Convert color space color1 = color.convert(space, norm=False).normalize() @@ -156,49 +162,65 @@ def harmonize(self, color: 'Color', space: Optional[str]) -> List['Color']: if not is_cyl and not isinstance(color1._space, (Labish, Regular)): raise ValueError('Unsupported color space type {}'.format(color.space())) - # Trim off black and white unless the color is achromatic, - # But always trim duplicate target color from left side. - if not color1.is_achromatic(): - ltrim, rtrim = slice(1, -1, None), slice(None, -1, None) - else: - ltrim, rtrim = slice(None, -1, None), slice(None, None, None) + # If only one color is requested, just return the current color. + if count == 1: + return [color1] # Create black and white so we can generate tints and shades # Ensure hue and alpha is masked so we don't interpolate them. mask = ['hue', 'alpha'] if is_cyl else ['alpha'] - w = color1.new('xyz-d65', WHITE, alg.nan) + w = color1.new('xyz-d65', WHITE, math.nan) + max_lum = w[1] w.convert(space, fit=True, in_place=True, norm=False).mask(mask, in_place=True) - b = color1.new('xyz-d65', BLACK, alg.nan) + b = color1.new('xyz-d65', BLACK, math.nan) + min_lum = b[1] b.convert(space, fit=True, in_place=True, norm=False).mask(mask, in_place=True) + # Minimum steps should be adjusted to account for trimming off white and + # black if the color is not achromatic. Additionally, prepare our slice + # to remove black and white if required, but always trim duplicate target + # color from left side. + if not color1.is_achromatic(): + min_steps = count + 3 + ltrim, rtrim = slice(1, -1, None), slice(None, -1, None) + else: + min_steps = count + 1 + ltrim, rtrim = slice(None, -1, None), slice(None, None, None) + # Calculate how many tints and shades we need to generate - db = b.delta_e(color1, method=self.DELTA_E) - dw = w.delta_e(color1, method=self.DELTA_E) - steps_w = int(alg.round_half_up((dw / (db + dw)) * self.RANGE)) - steps_b = self.RANGE - steps_w + luminance = color1.luminance() + if luminance <= min_lum: + steps_w = min_steps + steps_b = 0 + elif luminance >= max_lum: + steps_b = min_steps - 1 + steps_w = 0 + else: + db = b.delta_e(color1, method=self.DELTA_E) + dw = w.delta_e(color1, method=self.DELTA_E) + steps_w = int(alg.round_half_up((dw / (db + dw)) * min_steps)) + steps_b = min_steps - steps_w kwargs = { 'space': space, 'method': 'linear', 'out_space': space - } # type: Dict[str, Any] + } # type: dict[str, Any] # Very close to black or is black, no need to interpolate from black to current color if steps_b <= 1: left = [] if steps_b == 1: left.extend(color1.steps([b, color1], steps=steps_b, **kwargs)) - steps = min(self.RANGE - (1 + steps_b), steps_w) - right = color1.steps([color1, w], steps=steps, **kwargs)[rtrim] + right = color1.steps([color1, w], steps=min(min_steps - (1 + steps_b), steps_w), **kwargs)[rtrim] # Very close to white or is white, no need to interpolate from current color to white elif steps_w <= 1: right = [] if steps_w == 1: right.extend(color1.steps([color1, w], steps=steps_w, **kwargs)) - steps = min(self.RANGE - (1 + steps_w), steps_b) right.insert(0, color1.clone()) - left = color1.steps([b, color1], steps=steps, **kwargs)[ltrim] + left = color1.steps([b, color1], steps=min(min_steps - (1 + steps_w), steps_b), **kwargs)[ltrim] # Anything else in between else: @@ -208,12 +230,12 @@ def harmonize(self, color: 'Color', space: Optional[str]) -> List['Color']: # Extract a subset of the results len_l = len(left) len_r = len(right) - l = int(self.STEPS // 2) - r = l + (1 if self.STEPS % 2 else 0) + l = int(count // 2) + r = l + (1 if count % 2 else 0) if len_r < r: - return left[-self.STEPS + len_r:] + right + return left[-count + len_r:] + right elif len_l < l: - return left + right[:self.STEPS - len_l] + return left + right[:count - len_l] return left[-l:] + right[:r] @@ -225,7 +247,7 @@ def __init__(self) -> None: self.count = 12 - def harmonize(self, color: 'Color', space: str) -> List['Color']: + def harmonize(self, color: Color, space: str) -> list[Color]: """Get color harmonies.""" # Get the color cylinder @@ -253,7 +275,7 @@ def harmonize(self, color: 'Color', space: str) -> List['Color']: class Wheel(Geometric): """Generate a color wheel.""" - def harmonize(self, color: 'Color', space: str, count: int = 12) -> List['Color']: + def harmonize(self, color: Color, space: str, count: int = 12) -> list[Color]: """Generate a color wheel with the given count.""" self.count = count @@ -290,7 +312,7 @@ def __init__(self) -> None: class SplitComplementary(Harmony): """Split Complementary colors.""" - def harmonize(self, color: 'Color', space: str) -> List['Color']: + def harmonize(self, color: Color, space: str) -> list[Color]: """Get color harmonies.""" # Get the color cylinder @@ -312,7 +334,7 @@ def harmonize(self, color: 'Color', space: str) -> List['Color']: class Analogous(Harmony): """Analogous colors.""" - def harmonize(self, color: 'Color', space: str) -> List['Color']: + def harmonize(self, color: Color, space: str) -> list[Color]: """Get color harmonies.""" color1 = self.get_cylinder(color, space) @@ -333,7 +355,7 @@ def harmonize(self, color: 'Color', space: str) -> List['Color']: class TetradicRect(Harmony): """Tetradic (rectangular) colors.""" - def harmonize(self, color: 'Color', space: str) -> List['Color']: + def harmonize(self, color: Color, space: str) -> list[Color]: """Get color harmonies.""" # Get the color cylinder @@ -362,10 +384,10 @@ def harmonize(self, color: 'Color', space: str) -> List['Color']: 'analogous': Analogous(), 'mono': Monochromatic(), 'wheel': Wheel() -} # type: Dict[str, Harmony] +} # type: dict[str, Harmony] -def harmonize(color: 'Color', name: str, space: str, **kwargs: Any) -> List['Color']: +def harmonize(color: Color, name: str, space: str, **kwargs: Any) -> list[Color]: """Get specified color harmonies.""" h = SUPPORTED.get(name) diff --git a/lib/coloraide/interpolate/__init__.py b/lib/coloraide/interpolate/__init__.py index a375e2d..abfeb1c 100644 --- a/lib/coloraide/interpolate/__init__.py +++ b/lib/coloraide/interpolate/__init__.py @@ -13,14 +13,14 @@ Original Authors: Lea Verou, Chris Lilley License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json) """ +from __future__ import annotations import math import functools from abc import ABCMeta, abstractmethod -from .. import util from .. import algebra as alg from .. spaces import HSVish, HSLish, Cylindrical, RGBish, LChish, Labish -from ..types import Vector, ColorInput, Plugin -from typing import Callable, Dict, Tuple, Optional, Type, Sequence, Union, Mapping, List, Any, TYPE_CHECKING +from ..types import Matrix, Vector, ColorInput, Plugin +from typing import Callable, Sequence, Mapping, Any, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -43,7 +43,7 @@ def __init__(self, color: ColorInput, value: float) -> None: def midpoint(t: float, h: float = 0.5) -> float: """Midpoint easing function.""" - return 0.0 if h <= 0 or h >= 1 else alg.npow(t, math.log(0.5) / math.log(h)) + return 0.0 if h <= 0 or h >= 1 else alg.spow(t, math.log(0.5) / math.log(h)) def hint(mid: float) -> Callable[..., float]: @@ -52,7 +52,7 @@ def hint(mid: float) -> Callable[..., float]: return functools.partial(midpoint, h=mid) -def normalize_domain(d: List[float]) -> List[float]: +def normalize_domain(d: Vector) -> Vector: """Normalize domain between 0 and 1.""" total = d[-1] - d[0] @@ -70,18 +70,19 @@ class Interpolator(metaclass=ABCMeta): def __init__( self, - coordinates: List[Vector], + coordinates: Matrix, channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], + create: type[Color], + easings: list[Callable[..., float] | None], + stops: dict[int, float], space: str, out_space: str, - progress: Optional[Union[Callable[..., float], Mapping[str, Callable[..., float]]]], + progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None, premultiplied: bool, extrapolate: bool = False, - domain: Optional[Sequence[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, + domain: Sequence[float] | None = None, + padding: float | tuple[float, float] | None = None, + hue: str = 'shorter', **kwargs: Any ): """Initialize.""" @@ -98,21 +99,22 @@ def __init__( self.space = space self._out_space = out_space self.extrapolate = extrapolate - self.current_easing = None # type: Optional[Union[Callable[..., float], Mapping[str, Callable[..., float]]]] + self.current_easing = None # type: Mapping[str, Callable[..., float]] | Callable[..., float] | None + self.hue = hue cs = self.create.CS_MAP[space] - if isinstance(cs, Cylindrical): - self.hue_index = cs.hue_index() + if cs.is_polar(): + self.hue_index = cs.hue_index() # type: ignore[attr-defined] else: self.hue_index = -1 self.premultiplied = premultiplied # Calculate padded start and end - self._padding = None # type: Optional[Tuple[float, float]] + self._padding = None # type: tuple[float, float] | None if padding is not None: self.padding(padding) # Set the domain - self._domain = [] # type: List[float] + self._domain = [] # type: Vector if domain is not None: self.domain(domain) @@ -123,12 +125,18 @@ def discretize( steps: int = 2, max_steps: int = 1000, max_delta_e: float = 0, - delta_e: Optional[str] = None - ) -> None: + delta_e: str | None = None, + delta_e_args: dict[str, Any] | None = None, + ) -> Interpolator: """Make the interpolation a discretized interpolation.""" + from .linear import Linear + # Get the discrete steps for the new discrete interpolation - colors = self.steps(steps, max_steps, max_delta_e, delta_e) + colors = self.steps(steps, max_steps, max_delta_e, delta_e, delta_e_args) + + if not colors: + raise ValueError('Discrete interpolation requires at least 1 discrete step.') # Calculate new coordinate list and discrete stops total = len(colors) @@ -146,21 +154,28 @@ def discretize( coords.extend([step1, step2]) count += 2 - # Update colors and stops - self.coordinates = coords - self.length = len(self.coordinates) - self.stops = stops - self.start = self.stops[0] - self.end = self.stops[len(self.stops) - 1] - - # Reset features that were used to generate the discrete steps - self.easings = [None] * (self.length - 1) - self.progress = None - self.current_easing = None - self._padding = None - self._domain = [] - - self.setup() + hue = self.hue + if total == 1: + coords.extend([colors[-1][:], colors[-1][:]]) + stops[0] = 0.0 + stops[1] = 1.0 + hue = 'shorter' + + return Linear().interpolator( + coordinates=coords, + channel_names=self.channel_names, + create=self.create, + easings=[None] * (len(coords) - 1), + stops=stops, + space=self.space, + out_space=self._out_space, + progress=self.progress, + premultiplied=self.premultiplied, + extrapolate=self.extrapolate, + domain=[], + padding=None, + hue = hue + ) def out_space(self, space: str) -> None: """Set output space.""" @@ -169,7 +184,7 @@ def out_space(self, space: str) -> None: raise ValueError("'{}' is not a valid color space".format(space)) self._out_space = space - def padding(self, padding: Union[float, Sequence[float]]) -> None: + def padding(self, padding: float | Sequence[float]) -> None: """Add/adjust padding.""" # Make sure it is a sequence @@ -203,7 +218,7 @@ def domain(self, domain: Sequence[float]) -> None: # Ensure domain ascends. # If we have a domain of length 1, we will duplicate it. - d = [] # type: List[float] + d = [] # type: Vector if domain: length = len(domain) @@ -237,12 +252,16 @@ def steps( steps: int = 2, max_steps: int = 1000, max_delta_e: float = 0, - delta_e: Optional[str] = None - ) -> List['Color']: + delta_e: str | None = None, + delta_e_args: dict[str, Any] | None = None, + ) -> list[Color]: """Steps.""" actual_steps = steps + if delta_e_args is None: + delta_e_args = {} + # Allocate at least two steps if we are doing a maximum delta E, if max_delta_e != 0 and actual_steps < 2: actual_steps = 2 @@ -251,7 +270,7 @@ def steps( if max_steps is not None: actual_steps = min(actual_steps, max_steps) - ret = [] # type: List[Tuple[float, Color]] + ret = [] # type: list[tuple[float, Color]] if actual_steps == 1: ret = [(0.5, self(0.5))] elif actual_steps > 1: @@ -271,7 +290,8 @@ def steps( m_delta, ret[i - 1][1].delta_e( ret[i][1], - method=delta_e + method=delta_e, + **delta_e_args ) ) @@ -290,8 +310,8 @@ def steps( color = self(p) m_delta = max( m_delta, - color.delta_e(prev[1], method=delta_e), - color.delta_e(cur[1], method=delta_e) + color.delta_e(prev[1], method=delta_e, **delta_e_args), + color.delta_e(cur[1], method=delta_e, **delta_e_args) ) ret.insert(index, (p, color)) total += 1 @@ -299,7 +319,7 @@ def steps( return [i[1] for i in ret] - def premultiply(self, coords: Vector, alpha: Optional[float] = None) -> None: + def premultiply(self, coords: Vector, alpha: float | None = None) -> None: if alpha is not None: coords[-1] = alpha @@ -333,7 +353,7 @@ def postdivide(self, coords: Vector) -> None: coords[i] = value / alpha - def begin(self, point: float, first: float, last: float, index: int) -> 'Color': + def begin(self, point: float, first: float, last: float, index: int) -> Color: """ Begin interpolation. @@ -409,7 +429,7 @@ def scale(self, point: float) -> float: point = size * index + (adjusted * size) return point - def __call__(self, point: float) -> 'Color': + def __call__(self, point: float) -> Color: """Find which leg of the interpolation the request is between.""" if self._domain: @@ -450,24 +470,25 @@ class Interpolate(Plugin, metaclass=ABCMeta): @abstractmethod def interpolator( self, - coordinates: List[Vector], + coordinates: Matrix, channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], + create: type[Color], + easings: list[Callable[..., float] | None], + stops: dict[int, float], space: str, out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], + progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None, premultiplied: bool, extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, + domain: Vector | None = None, + padding: float | tuple[float, float] | None = None, + hue: str = 'shorter', **kwargs: Any ) -> Interpolator: """Get the interpolator object.""" -def calc_stops(stops: Dict[int, float], count: int) -> Dict[int, float]: +def calc_stops(stops: dict[int, float], count: int) -> dict[int, float]: """Calculate stops.""" # Ensure the first stop is set to zero if not explicitly set @@ -533,9 +554,9 @@ def calc_stops(stops: Dict[int, float], count: int) -> Dict[int, float]: def process_mapping( - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], + progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None, aliases: Mapping[str, str] -) -> Optional[Union[Callable[..., float], Mapping[str, Callable[..., float]]]]: +) -> Mapping[str, Callable[..., float]] | Callable[..., float] | None: """Process a mapping, such that it is not using aliases.""" if not isinstance(progress, Mapping): @@ -543,94 +564,7 @@ def process_mapping( return {aliases.get(k, k): v for k, v in progress.items()} -def adjust_shorter(h1: float, h2: float, offset: float) -> Tuple[float, float]: - """Adjust the given hues.""" - - d = h2 - h1 - if d > 180: - h2 -= 360.0 - offset -= 360.0 - elif d < -180: - h2 += 360 - offset += 360.0 - return h2, offset - - -def adjust_longer(h1: float, h2: float, offset: float) -> Tuple[float, float]: - """Adjust the given hues.""" - - d = h2 - h1 - if 0 < d < 180: - h2 -= 360.0 - offset -= 360.0 - elif -180 < d <= 0: - h2 += 360 - offset += 360.0 - return h2, offset - - -def adjust_increase(h1: float, h2: float, offset: float) -> Tuple[float, float]: - """Adjust the given hues.""" - - if h2 < h1: - h2 += 360.0 - offset += 360.0 - return h2, offset - - -def adjust_decrease(h1: float, h2: float, offset: float) -> Tuple[float, float]: - """Adjust the given hues.""" - - if h2 > h1: - h2 -= 360.0 - offset -= 360.0 - return h2, offset - - -def normalize_hue( - color1: Vector, - color2: Optional[Vector], - index: int, - offset: float, - hue: str, - fallback: Optional[float] -) -> Tuple[Vector, float]: - """Normalize hues according the hue specifier.""" - - if hue == 'specified': - return (color2 or color1), offset - - # Probably the first hue - if color2 is None: - color1[index] = util.constrain_hue(color1[index]) - return color1, offset - - if hue == 'shorter': - adjuster = adjust_shorter - elif hue == 'longer': - adjuster = adjust_longer - elif hue == 'increasing': - adjuster = adjust_increase - elif hue == 'decreasing': - adjuster = adjust_decrease - else: - raise ValueError("Unknown hue adjuster '{}'".format(hue)) - - c1 = color1[index] + offset - c2 = util.constrain_hue(color2[index]) + offset - - # Adjust hue, handle gaps across `NaN`s - if not math.isnan(c2): - if not math.isnan(c1): - c2, offset = adjuster(c1, c2, offset) - elif fallback is not None: - c2, offset = adjuster(fallback, c2, offset) - - color2[index] = c2 - return color2, offset - - -def carryforward_convert(color: 'Color', space: str, hue_index: int, powerless: bool) -> None: # pragma: no cover +def carryforward_convert(color: Color, space: str, hue_index: int, powerless: bool) -> None: # pragma: no cover """Carry forward undefined values during conversion.""" carry = [] @@ -720,16 +654,16 @@ def carryforward_convert(color: 'Color', space: str, hue_index: int, powerless: def interpolator( interpolator: str, - create: Type['Color'], - colors: Sequence[Union[ColorInput, stop, Callable[..., float]]], - space: Optional[str], - out_space: Optional[str], - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], + create: type[Color], + colors: Sequence[ColorInput | stop | Callable[..., float]], + space: str | None, + out_space: str | None, + progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None, hue: str, premultiplied: bool, extrapolate: bool, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, + domain: Vector | None = None, + padding: float | tuple[float, float] | None = None, carryforward: bool = False, powerless: bool = False, **kwargs: Any @@ -746,6 +680,9 @@ def interpolator( if space is None: space = create.INTERPOLATE + if not colors: + raise ValueError('At least one color must be specified.') + if isinstance(colors[0], stop): current = create(colors[0].color) stops[0] = colors[0].stop @@ -769,19 +706,9 @@ def interpolator( elif powerless and is_cyl and current.is_achromatic(): current[hue_index] = math.nan - # Normalize hue - offset = 0.0 - norm_coords = current[:] - fallback = None - if hue_index >= 0: - h = norm_coords[hue_index] - norm_coords, offset = normalize_hue(norm_coords, None, hue_index, offset, hue, fallback) - if not math.isnan(h): - fallback = h - easing = None # type: Any easings = [] # type: Any - coords = [norm_coords] + coords = [current[:]] i = 0 for x in colors[1:]: @@ -807,16 +734,8 @@ def interpolator( elif powerless and is_cyl and color.is_achromatic(): color[hue_index] = math.nan - # Normalize the hue - norm_coords = color[:] - if hue_index >= 0: - h = norm_coords[hue_index] - norm_coords, offset = normalize_hue(current[:], norm_coords, hue_index, offset, hue, fallback) - if not math.isnan(h): - fallback = h - # Create an entry interpolating the current color and the next color - coords.append(norm_coords) + coords.append(color[:]) easings.append(easing if easing is not None else progress) # The "next" color is now the "current" color @@ -824,11 +743,16 @@ def interpolator( current = color i += 1 - if i < 2: - raise ValueError('Need at least two colors to interpolate') + if i == 1: + coords.append(coords[-1][:]) + easings.append(None) + stops[i] = None + hue = 'shorter' + i += 1 # Calculate stops stops = calc_stops(stops, i) + kwargs['hue'] = hue # Send the interpolation list along with the stop map to the Piecewise interpolator return plugin.interpolator( diff --git a/lib/coloraide/interpolate/bspline.py b/lib/coloraide/interpolate/bspline.py index 3c7fdef..c75cac9 100644 --- a/lib/coloraide/interpolate/bspline.py +++ b/lib/coloraide/interpolate/bspline.py @@ -5,14 +5,12 @@ https://www.math.ucla.edu/~baker/149.1.02w/handouts/dd_splines.pdf http://www2.cs.uregina.ca/~anima/408/Notes/Interpolation/UniformBSpline.htm """ +from __future__ import annotations from .. import algebra as alg from .continuous import InterpolatorContinuous from ..interpolate import Interpolator, Interpolate from ..types import Vector -from typing import Optional, Callable, Mapping, List, Union, Sequence, Dict, Any, Tuple, Type, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from typing import Any class InterpolatorBSpline(InterpolatorContinuous): @@ -79,36 +77,7 @@ class BSpline(Interpolate): NAME = "bspline" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: """Return the B-spline interpolator.""" - return InterpolatorBSpline( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + return InterpolatorBSpline(*args, **kwargs) diff --git a/lib/coloraide/interpolate/bspline_natural.py b/lib/coloraide/interpolate/bspline_natural.py index fdc8b89..78cc9b9 100644 --- a/lib/coloraide/interpolate/bspline_natural.py +++ b/lib/coloraide/interpolate/bspline_natural.py @@ -3,14 +3,11 @@ https://www.math.ucla.edu/~baker/149.1.02w/handouts/dd_splines.pdf. """ +from __future__ import annotations from .. interpolate import Interpolate, Interpolator from .bspline import InterpolatorBSpline from .. import algebra as alg -from ..types import Vector -from typing import List, Sequence, Any, Optional, Union, Mapping, Callable, Dict, Tuple, Type, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from typing import Any class InterpolatorNaturalBSpline(InterpolatorBSpline): @@ -38,36 +35,7 @@ class NaturalBSpline(Interpolate): NAME = "natural" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: """Return the natural B-spline interpolator.""" - return InterpolatorNaturalBSpline( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + return InterpolatorNaturalBSpline(*args, **kwargs) diff --git a/lib/coloraide/interpolate/catmull_rom.py b/lib/coloraide/interpolate/catmull_rom.py index 4c26c38..0a39f32 100644 --- a/lib/coloraide/interpolate/catmull_rom.py +++ b/lib/coloraide/interpolate/catmull_rom.py @@ -3,14 +3,11 @@ http://www2.cs.uregina.ca/~anima/408/Notes/Interpolation/Parameterized-Curves-Summary.htm """ +from __future__ import annotations from .bspline import InterpolatorBSpline from ..interpolate import Interpolator, Interpolate from .. import algebra as alg -from ..types import Vector -from typing import Optional, Callable, Mapping, List, Union, Sequence, Dict, Tuple, Any, Type, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from typing import Any class InterpolatorCatmullRom(InterpolatorBSpline): @@ -28,36 +25,7 @@ class CatmullRom(Interpolate): NAME = "catrom" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: """Return the Catmull-Rom interpolator.""" - return InterpolatorCatmullRom( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + return InterpolatorCatmullRom(*args, **kwargs) diff --git a/lib/coloraide/interpolate/continuous.py b/lib/coloraide/interpolate/continuous.py index 063b763..1144455 100644 --- a/lib/coloraide/interpolate/continuous.py +++ b/lib/coloraide/interpolate/continuous.py @@ -1,18 +1,108 @@ """Continuous interpolation.""" +from __future__ import annotations import math from .. import algebra as alg from ..interpolate import Interpolator, Interpolate from ..types import Vector -from typing import Callable, Mapping, Sequence, Any, TYPE_CHECKING -from typing import Optional, Callable, Mapping, List, Union, Sequence, Dict, Tuple, Any, Type, TYPE_CHECKING +from typing import Any -if TYPE_CHECKING: # pragma: no cover - from ..color import Color + +def adjust_shorter(h1: float, h2: float, offset: float) -> tuple[float, float]: + """Adjust the given hues.""" + + d = h2 - h1 + if d > 180: + h2 -= 360.0 + offset -= 360.0 + elif d < -180: + h2 += 360 + offset += 360.0 + return h2, offset + + +def adjust_longer(h1: float, h2: float, offset: float) -> tuple[float, float]: + """Adjust the given hues.""" + + d = h2 - h1 + if 0 < d < 180: + h2 -= 360.0 + offset -= 360.0 + elif -180 < d <= 0: + h2 += 360 + offset += 360.0 + return h2, offset + + +def adjust_increase(h1: float, h2: float, offset: float) -> tuple[float, float]: + """Adjust the given hues.""" + + if h2 < h1: + h2 += 360.0 + offset += 360.0 + return h2, offset + + +def adjust_decrease(h1: float, h2: float, offset: float) -> tuple[float, float]: + """Adjust the given hues.""" + + if h2 > h1: + h2 -= 360.0 + offset -= 360.0 + return h2, offset class InterpolatorContinuous(Interpolator): """Interpolate with continuous piecewise.""" + def normalize_hue( + self, + color1: Vector, + color2: Vector | None, + offset: float, + hue: str, + fallback: float | None + ) -> tuple[Vector, float]: + """ + Normalize hues according the hue specifier. + + Hues are normalized in a continuous way such that the fix-up is applied + relative to the hues that come before it. + """ + + index = self.hue_index + + if hue == 'specified': + return (color2 or color1), offset + + # Probably the first hue + if color2 is None: + color1[index] = color1[index] % 360 + return color1, offset + + if hue == 'shorter': + adjuster = adjust_shorter + elif hue == 'longer': + adjuster = adjust_longer + elif hue == 'increasing': + adjuster = adjust_increase + elif hue == 'decreasing': + adjuster = adjust_decrease + else: + raise ValueError("Unknown hue adjuster '{}'".format(hue)) + + c1 = color1[index] + offset + c2 = (color2[index] % 360) + offset + + # Adjust hue, handle gaps across `NaN`s + if not math.isnan(c2): + if not math.isnan(c1): + c2, offset = adjuster(c1, c2, offset) + elif fallback is not None: + c2, offset = adjuster(fallback, c2, offset) + + color2[index] = c2 + return color2, offset + def handle_undefined(self) -> None: """ Handle null values. @@ -28,6 +118,28 @@ def handle_undefined(self) -> None: """ coords = self.coordinates + end = self.length - 2 + hue_index = self.hue_index + + # Normalize hue + offset = 0.0 + fallback = None + if hue_index >= 0: + first = self.coordinates[0] + h = first[hue_index] + self.coordinates[0], offset = self.normalize_hue(first, None, offset, self.hue, fallback) + if not math.isnan(h): + fallback = h + + i = 0 + while i <= end: + c1, c2 = self.coordinates[i:i + 2] + if hue_index >= 0: + h = c2[hue_index] + self.coordinates[i + 1], offset = self.normalize_hue(c1, c2, offset, self.hue, fallback) + if not math.isnan(h): + fallback = h + i += 1 # Process each set of coordinates alpha = len(coords[0]) - 1 @@ -128,36 +240,7 @@ class Continuous(Interpolate): NAME = "continuous" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: - """Return the B-spline interpolator.""" - - return InterpolatorContinuous( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + """Return the continuous interpolator.""" + + return InterpolatorContinuous(*args, **kwargs) diff --git a/lib/coloraide/interpolate/css_linear.py b/lib/coloraide/interpolate/css_linear.py new file mode 100644 index 0000000..520b352 --- /dev/null +++ b/lib/coloraide/interpolate/css_linear.py @@ -0,0 +1,91 @@ +"""Piecewise linear interpolation.""" +from __future__ import annotations +import math +from .linear import InterpolatorLinear +from ..interpolate import Interpolator, Interpolate +from ..types import Vector +from typing import Any + + +class InterpolatorCSSLinear(InterpolatorLinear): + """Interpolate multiple ranges of colors using linear, Piecewise interpolation, but adhere to CSS requirements.""" + + def normalize_hue( + self, + color1: Vector, + color2: Vector, + hue: str + ) -> None: + """ + Adjust hues. + + Hues are applied to match CSS. This means the undefined hues are resolved + before fix-up such that during hue-fix, undefined hues will assume the value + of the other color (if the hue is defined) creating an arc length. Since + interpolation between a non-achromatic color and achromatic color will + now have a false arc length, hue specifications such as shorter and longer + will produce different results in such cases. This is done purposely in + CSS. + + In non-CSS linear interpolation, undefined hue resolution is performed later + and yields a result that such that their is no hue arc which gives more + intuitive results with achromatic colors. + """ + + index = self.hue_index + + c1 = color1[index] + c2 = color2[index] + + is_nan1 = math.isnan(c1) + is_nan2 = math.isnan(c2) + + if is_nan1 and is_nan2: + return + elif is_nan1: + c1 = c2 + elif is_nan2: + c2 = c1 + + if hue == "specified": + return + + c1 %= 360 + c2 %= 360 + + if hue == "shorter": + if c2 - c1 > 180: + c1 += 360 + elif c2 - c1 < -180: + c2 += 360 + + elif hue == "longer": + if 0 < (c2 - c1) < 180: + c1 += 360 + elif -180 < (c2 - c1) <= 0: + c2 += 360 + + elif hue == "increasing": + if c2 < c1: + c2 += 360 + + elif hue == "decreasing": + if c1 < c2: + c1 += 360 + + else: + raise ValueError("Unknown hue adjuster '{}'".format(hue)) + + color1[index] = c1 + color2[index] = c2 + + +class CSSLinear(Interpolate): + """CSS Linear interpolation plugin.""" + + NAME = "css-linear" + + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: + """Return the CSS linear interpolator.""" + + return InterpolatorCSSLinear(*args, **kwargs) diff --git a/lib/coloraide/interpolate/linear.py b/lib/coloraide/interpolate/linear.py index e1bed7f..ebe5f9c 100644 --- a/lib/coloraide/interpolate/linear.py +++ b/lib/coloraide/interpolate/linear.py @@ -1,17 +1,72 @@ """Piecewise linear interpolation.""" +from __future__ import annotations import math from .. import algebra as alg from ..interpolate import Interpolator, Interpolate from ..types import Vector -from typing import Optional, Callable, Mapping, Union, Any, Type, Sequence, List, Tuple, Dict, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from typing import Any class InterpolatorLinear(Interpolator): """Interpolate multiple ranges of colors using linear, Piecewise interpolation.""" + def normalize_hue( + self, + color1: Vector, + color2: Vector, + hue: str + ) -> None: + """ + Adjust hues. + + Undefined hues are not resolved at this point in time. + When interpolating between achromatic colors, hue specifications + such as shorter and longer will have no affect as undefined hues + will remain undefined meaning there is no arc length to choose + between. This gives more intuitive interpolation results. + """ + + index = self.hue_index + + c1 = color1[index] + c2 = color2[index] + + if hue == "specified": + return + + c1 %= 360 + c2 %= 360 + + if math.isnan(c1) or math.isnan(c2): + return + + if hue == "shorter": + if c2 - c1 > 180: + c1 += 360 + elif c2 - c1 < -180: + c2 += 360 + + elif hue == "longer": + if 0 < (c2 - c1) < 180: + c1 += 360 + elif -180 < (c2 - c1) <= 0: + c2 += 360 + + elif hue == "increasing": + if c2 < c1: + c2 += 360 + + elif hue == "decreasing": + if c1 < c2: + c1 += 360 + + else: + raise ValueError("Unknown hue adjuster '{}'".format(hue)) + + color1[index] = c1 + color2[index] = c2 + + def setup(self) -> None: """Setup for linear interpolation.""" @@ -28,6 +83,10 @@ def setup(self) -> None: self.coordinates.insert(i + 2, c2[:]) end += 1 + if self.hue_index >= 0: + self.normalize_hue(c1, c2, self.hue) + self.coordinates[i:i + 2] = [c1, c2] + # If we have a NaN for one alpha and the other alpha is not # Use the non-NaN alpha, but if we are premultiplied, we need # to now premultiply that coordinate set. @@ -69,7 +128,7 @@ def interpolate( # Both values are undefined, so return undefined if math.isnan(a) and math.isnan(b): - value = alg.nan + value = math.nan # One channel is undefined, take the one that is not elif math.isnan(a): @@ -91,36 +150,7 @@ class Linear(Interpolate): NAME = "linear" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: """Return the linear interpolator.""" - return InterpolatorLinear( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + return InterpolatorLinear(*args, **kwargs) diff --git a/lib/coloraide/interpolate/monotone.py b/lib/coloraide/interpolate/monotone.py index ccd29b4..239bc76 100644 --- a/lib/coloraide/interpolate/monotone.py +++ b/lib/coloraide/interpolate/monotone.py @@ -1,12 +1,9 @@ """Monotone interpolation based on a Hermite interpolation spline.""" +from __future__ import annotations from .bspline import InterpolatorBSpline from ..interpolate import Interpolator, Interpolate from .. import algebra as alg -from ..types import Vector -from typing import Optional, Callable, Mapping, List, Union, Sequence, Dict, Tuple, Any, Type, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color +from typing import Any class InterpolatorMonotone(InterpolatorBSpline): @@ -24,36 +21,7 @@ class Monotone(Interpolate): NAME = "monotone" - def interpolator( - self, - coordinates: List[Vector], - channel_names: Sequence[str], - create: Type['Color'], - easings: List[Optional[Callable[..., float]]], - stops: Dict[int, float], - space: str, - out_space: str, - progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], - premultiplied: bool, - extrapolate: bool = False, - domain: Optional[List[float]] = None, - padding: Optional[Union[float, Tuple[float, float]]] = None, - **kwargs: Any - ) -> Interpolator: + def interpolator(self, *args: Any, **kwargs: Any) -> Interpolator: """Return the monotone interpolator.""" - return InterpolatorMonotone( - coordinates, - channel_names, - create, - easings, - stops, - space, - out_space, - progress, - premultiplied, - extrapolate, - domain, - padding, - **kwargs - ) + return InterpolatorMonotone(*args, **kwargs) diff --git a/lib/coloraide/spaces/__init__.py b/lib/coloraide/spaces/__init__.py index 2509d4a..f7018e7 100644 --- a/lib/coloraide/spaces/__init__.py +++ b/lib/coloraide/spaces/__init__.py @@ -1,10 +1,10 @@ """Color base.""" +from __future__ import annotations from abc import ABCMeta, abstractmethod from ..channels import Channel from ..css import serialize -from ..deprecate import deprecated from ..types import VectorLike, Vector, Plugin -from typing import Tuple, Dict, Optional, Union, Any, List, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence import math if TYPE_CHECKING: # pragma: no cover @@ -18,6 +18,11 @@ class Regular: class Cylindrical: """Cylindrical space.""" + def radial_name(self) -> str: + """Radial name.""" + + return "s" + def hue_name(self) -> str: """Hue channel name.""" @@ -28,30 +33,40 @@ def hue_index(self) -> int: # pragma: no cover return self.get_channel_index(self.hue_name()) # type: ignore[no-any-return, attr-defined] + def radial_index(self) -> int: # pragma: no cover + """Get radial index.""" + + return self.get_channel_index(self.radial_name()) # type: ignore[no-any-return, attr-defined] + class RGBish(Regular): """RGB-ish space.""" - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return RGB-ish names in order R G B.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def indexes(self) -> List[int]: + def indexes(self) -> list[int]: """Return the index of RGB-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] + def linear(self) -> str: + """Will return the name of the space which is the linear version of itself (if available).""" + + return '' + class HSLish(Cylindrical): """HSL-ish space.""" - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return HSL-ish names in order H S L.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def indexes(self) -> List[int]: + def indexes(self) -> list[int]: """Return the index of HSL-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] @@ -60,12 +75,12 @@ def indexes(self) -> List[int]: class HSVish(Cylindrical): """HSV-ish space.""" - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return HSV-ish names in order H S V.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def indexes(self) -> List[int]: + def indexes(self) -> list[int]: """Return the index of HSV-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] @@ -74,12 +89,17 @@ def indexes(self) -> List[int]: class HWBish(Cylindrical): """HWB-ish space.""" - def names(self) -> Tuple[str, ...]: + def radial_name(self) -> str: + """Radial name.""" + + return "w" + + def names(self) -> tuple[str, ...]: """Return HWB-ish names in order H W B.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def indexes(self) -> List[int]: + def indexes(self) -> list[int]: """Return the index of HWB-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] @@ -88,24 +108,12 @@ def indexes(self) -> List[int]: class Labish: """Lab-ish color spaces.""" - @deprecated("Please use 'names' instead.") - def labish_names(self) -> Tuple[str, ...]: # pragma: no cover - """Return Lab-ish names in the order L a b.""" - - return self.names() - - @deprecated("Please use 'indexes' instead.") - def labish_indexes(self) -> List[int]: # pragma: no cover - """Return the index of the Lab-ish channels.""" - - return self.indexes() - - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return Lab-ish names in the order L a b.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def labish_indexes(self) -> List[int]: # pragma: no cover + def indexes(self) -> list[int]: """Return the index of the Lab-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] @@ -114,24 +122,17 @@ def labish_indexes(self) -> List[int]: # pragma: no cover class LChish(Cylindrical): """LCh-ish color spaces.""" - @deprecated("Please use 'names' instead.") - def lchish_names(self) -> Tuple[str, ...]: # pragma: no cover - """Return LCh-ish names in the order L c h.""" - - return self.names() - - @deprecated("Please use 'indexes' instead.") - def lchish_indexes(self) -> List[int]: # pragma: no cover - """Return the index of the Lab-ish channels.""" + def radial_name(self) -> str: + """Radial name.""" - return self.indexes() + return "c" - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return LCh-ish names in the order L c h.""" return self.channels[:-1] # type: ignore[no-any-return, attr-defined] - def indexes(self) -> List[int]: + def indexes(self) -> list[int]: """Return the index of the Lab-ish channels.""" return [self.get_channel_index(name) for name in self.names()] # type: ignore[attr-defined] @@ -143,11 +144,11 @@ def indexes(self) -> List[int]: class SpaceMeta(ABCMeta): """Ensure on subclass that the subclass has new instances of mappings.""" - def __init__(cls, name: str, bases: Tuple[object, ...], clsdict: Dict[str, Any]) -> None: + def __init__(cls, name: str, bases: tuple[object, ...], clsdict: dict[str, Any]) -> None: """Copy mappings on subclass.""" if len(cls.mro()) > 2: - cls.CHANNEL_ALIASES = cls.CHANNEL_ALIASES.copy() # type: Dict[str, str] + cls.CHANNEL_ALIASES = cls.CHANNEL_ALIASES.copy() # type: dict[str, str] class Space(Plugin, metaclass=SpaceMeta): @@ -157,22 +158,28 @@ class Space(Plugin, metaclass=SpaceMeta): # Color space name NAME = "" # Serialized name - SERIALIZE = () # type: Tuple[str, ...] + SERIALIZE = () # type: tuple[str, ...] # Channel names - CHANNELS = () # type: Tuple[Channel, ...] + CHANNELS = () # type: tuple[Channel, ...] # Channel aliases - CHANNEL_ALIASES = {} # type: Dict[str, str] + CHANNEL_ALIASES = {} # type: dict[str, str] # Enable or disable default color format parsing and serialization. COLOR_FORMAT = True - # Should this color also be checked in a different color space? Only when set to a string (specifying a color space) - # will the default gamut checking also check the specified space as well as the current. + # Some color spaces are a transform of a specific RGB color space gamut, e.g. HSL has a gamut of sRGB. + # When testing or gamut mapping a color within the current color space's gamut, `GAMUT_CHECK` will + # declare which space must be used as reference if anything other than the current space is required. # - # Gamut checking: - # The specified color space will be checked first followed by the original. Assuming the parent color space fits, - # the original should fit as well, but there are some cases when a parent color space that is slightly out of - # gamut, when evaluated with a threshold, may appear to be in gamut enough, but when checking the original color - # space, the values can be greatly out of specification (looking at you HSL). - GAMUT_CHECK = None # type: Optional[str] + # Specifically, when testing if a color is in gamut, both the origin space and the specified gamut + # space will be checked as sometimes a color is within the threshold of being "close enough" to the gamut, + # but the color can still be far outside the origin space's coordinates. Checking both ensures sane values + # that are also close enough to the gamut. + # + # When actually gamut mapping, only the gamut space is used, if none is specified, the origin space is used. + GAMUT_CHECK = None # type: str | None + # `CLIP_SPACE` forces a different space to be used for clipping than what is specified by `GAMUT_CHECK`. + # This is used in cases like HSL where the `GAMUT_CHECK` space is sRGB, but we want to clip in HSL as it + # is still reasonable and faster. + CLIP_SPACE = None # type: str | None # When set to `True`, this denotes that the color space has the ability to represent out of gamut in colors in an # extended range. When interpolation is done, if colors are interpolated in a smaller gamut than the colors being # interpolated, the colors will usually be gamut mapped, but if the interpolation space happens to support extended @@ -188,12 +195,21 @@ def __init__(self, **kwargs: Any) -> None: """Initialize.""" self.channels = self.CHANNELS + (alpha_channel,) + self._chan_index = {c: e for e, c in enumerate(self.channels)} # type: dict[str, int] self._color_ids = (self.NAME,) if not self.SERIALIZE else self.SERIALIZE + self._percents = ([True] * (len(self.channels) - 1)) + [False] + self._polar = isinstance(self, Cylindrical) + + def is_polar(self) -> bool: + """Return if the space is polar.""" + + return self._polar def get_channel_index(self, name: str) -> int: """Get channel index.""" - return self.channels.index(self.CHANNEL_ALIASES.get(name, name)) + idx = self._chan_index.get(self.CHANNEL_ALIASES.get(name, name)) + return int(name) if idx is None else idx def resolve_channel(self, index: int, coords: Vector) -> float: """Resolve channels.""" @@ -201,12 +217,24 @@ def resolve_channel(self, index: int, coords: Vector) -> float: value = coords[index] return self.channels[index].nans if math.isnan(value) else value - def _serialize(self) -> Tuple[str, ...]: + def _serialize(self) -> tuple[str, ...]: """Get the serialized name.""" return self._color_ids - def is_achromatic(self, coords: Vector) -> Optional[bool]: # pragma: no cover + def normalize(self, coords: Vector) -> Vector: + """ + Normalize coordinates. + + This allows a color space to normalize valid, but non-standard coordinates. + An example is cylindrical spaces with negative chroma/saturation. Such models + often have a valid, positive chroma/saturation and hue configuration that + matches the same color. + """ + + return coords + + def is_achromatic(self, coords: Vector) -> bool | None: # pragma: no cover """Check if color is achromatic.""" return None @@ -227,12 +255,13 @@ def from_base(self, coords: Vector) -> Vector: # pragma: no cover def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[bool, str] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: str | bool | dict[str, Any] = True, none: bool = False, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS 'color' string: `color(space coords+ / alpha)`.""" @@ -243,7 +272,8 @@ def to_string( alpha=alpha, precision=precision, fit=fit, - none=none + none=none, + percent=percent ) def match( @@ -251,7 +281,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a color by string.""" return None diff --git a/lib/coloraide/spaces/a98_rgb.py b/lib/coloraide/spaces/a98_rgb.py index c5cf51e..f23f996 100644 --- a/lib/coloraide/spaces/a98_rgb.py +++ b/lib/coloraide/spaces/a98_rgb.py @@ -1,6 +1,6 @@ """A98 RGB color class.""" -from ..cat import WHITES -from .srgb import sRGB +from __future__ import annotations +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -8,21 +8,25 @@ def lin_a98rgb(rgb: Vector) -> Vector: """Convert an array of a98-rgb values in the range 0.0 - 1.0 to linear light (un-corrected) form.""" - return [alg.npow(val, 563 / 256) for val in rgb] + return [alg.spow(val, 563 / 256) for val in rgb] def gam_a98rgb(rgb: Vector) -> Vector: """Convert an array of linear-light a98-rgb in the range 0.0-1.0 to gamma corrected form.""" - return [alg.npow(val, 256 / 563) for val in rgb] + return [alg.spow(val, 256 / 563) for val in rgb] -class A98RGB(sRGB): +class A98RGB(sRGBLinear): """A98 RGB class.""" BASE = "a98-rgb-linear" NAME = "a98-rgb" - WHITE = WHITES['2deg']['D65'] + + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE def to_base(self, coords: Vector) -> Vector: """To XYZ from A98 RGB.""" diff --git a/lib/coloraide/spaces/a98_rgb_linear.py b/lib/coloraide/spaces/a98_rgb_linear.py index c5f0c24..e0168db 100644 --- a/lib/coloraide/spaces/a98_rgb_linear.py +++ b/lib/coloraide/spaces/a98_rgb_linear.py @@ -1,6 +1,6 @@ """Linear A98 RGB color class.""" -from ..cat import WHITES -from .srgb import sRGB +from __future__ import annotations +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -27,22 +27,21 @@ def lin_a98rgb_to_xyz(rgb: Vector) -> Vector: https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf """ - return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_a98rgb(xyz: Vector) -> Vector: """Convert XYZ to linear-light a98-rgb.""" - return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) -class A98RGBLinear(sRGB): +class A98RGBLinear(sRGBLinear): """Linear A98 RGB class.""" BASE = "xyz-d65" NAME = "a98-rgb-linear" SERIALIZE = ('--a98-rgb-linear',) - WHITE = WHITES['2deg']['D65'] def to_base(self, coords: Vector) -> Vector: """To XYZ from A98 RGB.""" diff --git a/lib/coloraide/spaces/aces2065_1.py b/lib/coloraide/spaces/aces2065_1.py index 4b2d3cf..2efc700 100644 --- a/lib/coloraide/spaces/aces2065_1.py +++ b/lib/coloraide/spaces/aces2065_1.py @@ -3,11 +3,11 @@ https://www.oscars.org/science-technology/aces/aces-documentation """ +from __future__ import annotations from ..channels import Channel -from ..spaces.srgb import sRGB +from ..spaces.srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector -from typing import Tuple AP0_TO_XYZ = [ [0.9525523959381857, 0.0, 9.367863166046855e-05], @@ -28,21 +28,21 @@ def aces_to_xyz(aces: Vector) -> Vector: """Convert ACEScc to XYZ.""" - return alg.dot(AP0_TO_XYZ, aces, dims=alg.D2_D1) + return alg.matmul(AP0_TO_XYZ, aces, dims=alg.D2_D1) def xyz_to_aces(xyz: Vector) -> Vector: """Convert XYZ to ACEScc.""" - return alg.dot(XYZ_TO_AP0, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_AP0, xyz, dims=alg.D2_D1) -class ACES20651(sRGB): +class ACES20651(sRGBLinear): """The ACES color class.""" BASE = "xyz-d65" NAME = "aces2065-1" - SERIALIZE = ("--aces2065-1",) # type: Tuple[str, ...] + SERIALIZE = ("--aces2065-1",) WHITE = (0.32168, 0.33767) CHANNELS = ( Channel("r", 0.0, 65504.0, bound=True), diff --git a/lib/coloraide/spaces/acescc.py b/lib/coloraide/spaces/acescc.py index 3881ed3..a1bfc7a 100644 --- a/lib/coloraide/spaces/acescc.py +++ b/lib/coloraide/spaces/acescc.py @@ -3,11 +3,11 @@ https://www.oscars.org/science-technology/aces/aces-documentation """ +from __future__ import annotations import math from ..channels import Channel -from ..spaces.srgb import sRGB +from ..spaces.srgb_linear import sRGBLinear from ..types import Vector -from typing import Tuple CC_MIN = (math.log2(2 ** -16) + 9.72) / 17.52 CC_MAX = (math.log2(65504) + 9.72) / 17.52 @@ -50,12 +50,12 @@ def acescg_to_acescc(acescg: Vector) -> Vector: return acescc -class ACEScc(sRGB): +class ACEScc(sRGBLinear): """The ACEScc color class.""" BASE = "acescg" NAME = "acescc" - SERIALIZE = ("--acescc",) # type: Tuple[str, ...] + SERIALIZE = ("--acescc",) # type: tuple[str, ...] WHITE = (0.32168, 0.33767) CHANNELS = ( Channel("r", CC_MIN, CC_MAX, bound=True, nans=CC_MIN), @@ -64,6 +64,11 @@ class ACEScc(sRGB): ) DYNAMIC_RANGE = 'hdr' + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE + def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/acescct.py b/lib/coloraide/spaces/acescct.py index 8380780..5b0eb16 100644 --- a/lib/coloraide/spaces/acescct.py +++ b/lib/coloraide/spaces/acescct.py @@ -3,11 +3,11 @@ https://www.oscars.org/science-technology/aces/aces-documentation """ +from __future__ import annotations import math from ..channels import Channel -from ..spaces.srgb import sRGB +from ..spaces.srgb_linear import sRGBLinear from ..types import Vector -from typing import Tuple from .acescc import CC_MAX CCT_MIN = 0.0729055341958355 @@ -45,12 +45,12 @@ def acescg_to_acescct(acescg: Vector) -> Vector: return acescc -class ACEScct(sRGB): +class ACEScct(sRGBLinear): """The ACEScct color class.""" BASE = "acescg" NAME = "acescct" - SERIALIZE = ("--acescct",) # type: Tuple[str, ...] + SERIALIZE = ("--acescct",) # type: tuple[str, ...] WHITE = (0.32168, 0.33767) CHANNELS = ( Channel("r", CCT_MIN, CCT_MAX, bound=True, nans=CCT_MIN), @@ -59,6 +59,11 @@ class ACEScct(sRGB): ) DYNAMIC_RANGE = 'hdr' + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE + def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/acescg.py b/lib/coloraide/spaces/acescg.py index 65b857c..ad6394d 100644 --- a/lib/coloraide/spaces/acescg.py +++ b/lib/coloraide/spaces/acescg.py @@ -3,11 +3,11 @@ https://www.oscars.org/science-technology/aces/aces-documentation """ +from __future__ import annotations from ..channels import Channel -from ..spaces.srgb import sRGB +from ..spaces.srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector -from typing import Tuple AP1_TO_XYZ = [ [0.6624541811085053, 0.13400420645643313, 0.15618768700490782], @@ -25,21 +25,21 @@ def acescg_to_xyz(acescg: Vector) -> Vector: """Convert ACEScc to XYZ.""" - return alg.dot(AP1_TO_XYZ, acescg, dims=alg.D2_D1) + return alg.matmul(AP1_TO_XYZ, acescg, dims=alg.D2_D1) def xyz_to_acescg(xyz: Vector) -> Vector: """Convert XYZ to ACEScc.""" - return alg.dot(XYZ_TO_AP1, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_AP1, xyz, dims=alg.D2_D1) -class ACEScg(sRGB): +class ACEScg(sRGBLinear): """The ACEScg color class.""" BASE = "xyz-d65" NAME = "acescg" - SERIALIZE = ("--acescg",) # type: Tuple[str, ...] + SERIALIZE = ("--acescg",) # type: tuple[str, ...] WHITE = (0.32168, 0.33767) CHANNELS = ( Channel("r", 0.0, 65504.0, bound=True), diff --git a/lib/coloraide/spaces/achromatic.py b/lib/coloraide/spaces/achromatic.py deleted file mode 100644 index 679aeec..0000000 --- a/lib/coloraide/spaces/achromatic.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tools for dynamic achromatic response.""" -from .. import algebra as alg -import bisect -from typing import Any -from ..types import Vector -from abc import ABCMeta, abstractmethod -import math -from typing import List, Tuple, Optional - - -class Achromatic(metaclass=ABCMeta): - """Calculate a spline that follows a color's achromatic response.""" - - L_IDX = 0 - C_IDX = 1 - H_IDX = 2 - - def __init__( - self, - data: Optional[List[Vector]] = None, - threshold_upper: float = alg.inf, - threshold_lower: float = alg.inf, - threshold_cutoff: float = alg.inf, - spline: str = 'linear', - mirror: bool = False, - **kwargs: Any - ) -> None: - """ - Initialize. - - `tuning`: Either a dictionary of `low`, `mid`, and `high`, each specifying a start, end, step, and scale - used to build a portion of the spline representing the achromatic response or a list containing the - the pre-calculated spline points. - `threshold_upper`: threshold above achromatic curve. - `threshold_lower`: threshold below achromatic curve. - `threshold_cutoff`: threshold of chroma above which we will assume colors are not achromatic. - `spline`: spline type. - `mirror`: Mirror response across lightness axis at zero. - """ - - self.mirror = mirror - self.threshold_upper = threshold_upper - self.threshold_lower = threshold_lower - self.threshold_cutoff = threshold_cutoff - - self.domain = [] # type: List[float] - self.min_colorfulness = 1e10 - self.min_lightness = 1e10 - self.spline_type = spline - - # Create a spline that maps the achromatic range for the SDR range - if data is not None: - self.setup_achromatic_response(data, **kwargs) - - def dump(self) -> Optional[List[Vector]]: # pragma: no cover - """Dump data points.""" - - if self.spline_type == 'linear': - return list(zip(*self.spline.points)) - else: - # Strip off the data points used to coerce the spline through the end. - return list(zip(*self.spline.points))[1:-1] - - @abstractmethod - def convert(self, coords: Vector, **kwargs: Any) -> Vector: - """Convert an sRGB color to the desired space.""" - - def calc_achromatic_response( - self, - parameters: List[Tuple[int, int, int, float]], - **kwargs: Any - ) -> None: # pragma: no cover - """ - Calculate the achromatic response. - - Used to precalculate the best response. - """ - - points = [] # type: List[List[float]] - for segment in parameters: - start, end, step, scale = segment - for p in range(start, end, step): - color = self.convert([p / scale] * 3, **kwargs) - l, c, h = color[self.L_IDX], color[self.C_IDX], color[self.H_IDX] - if l < self.min_lightness: - self.min_lightness = l - if c < self.min_colorfulness: - self.min_colorfulness = c - self.domain.append(l) - points.append([l, c, h % 360]) - self.spline = alg.interpolate(points, method=self.spline_type) - self.hue = self.convert([1] * 3, **kwargs)[self.H_IDX] % 360 - self.ihue = (self.hue - 180) % 360 - - def setup_achromatic_response( - self, - tuning: List[Vector], - **kwargs: Any - ) -> None: - """Setup the achromatic response.""" - - points = [] # type: List[List[float]] - for entry in tuning: - l, c, h = entry - if l < self.min_lightness: - self.min_lightness = l - if c < self.min_colorfulness: - self.min_colorfulness = c - points.append([l, c, h]) - self.domain.append(l) - self.spline = alg.interpolate(points, method=self.spline_type) - self.hue = self.convert([1] * 3, **kwargs)[self.H_IDX] % 360 - self.ihue = (self.hue - 180) % 360 - - def scale(self, point: float) -> float: - """Scale the lightness to match the range.""" - - if point <= self.domain[0]: - point = (point - self.domain[0]) / (self.domain[-1] - self.domain[0]) - elif point >= self.domain[-1]: - point = 1.0 + (point - self.domain[-1]) / (self.domain[-1] - self.domain[0]) - else: - regions = len(self.domain) - 1 - size = (1 / regions) - index = 0 - adjusted = 0.0 - index = bisect.bisect(self.domain, point) - 1 - a, b = self.domain[index:index + 2] - l = b - a - adjusted = ((point - a) / l) if l else 0.0 - point = size * index + (adjusted * size) - return point - - def get_ideal_chroma(self, l: float) -> float: - """Get the ideal chroma.""" - - if math.isnan(l): - return 0.0 - - elif self.mirror and l < 0.0: - return self.spline(self.scale(abs(l)))[1] - - return self.spline(self.scale(l))[1] - - def get_ideal_hue(self, l: float) -> float: - """Get the ideal chroma.""" - - if math.isnan(l): - return 0.0 - - elif self.mirror and l < 0.0: - return (self.spline(self.scale(abs(l)))[2] - 180) % 360 - - return self.spline(self.scale(l))[2] - - def get_ideal_ab(self, l: float) -> Tuple[float, float]: - """Get the ideal rectangular form of chroma and hue, the components a and b.""" - - if math.isnan(l): - return 0.0, 0.0 - - return alg.polar_to_rect(self.get_ideal_chroma(l), self.get_ideal_hue(l)) - - def test(self, l: float, c: float, h: float) -> bool: - """Test if the current color is achromatic.""" - - # If colorfulness is past this limit, we'd have to have a lightness - # so high, that our test has already broken down. - if c > self.threshold_cutoff or (not self.mirror and l < 0.0): - return False - - # If we are higher than 1, we are extrapolating; - # otherwise, use the spline. - flip = self.mirror and l < 0.0 - la = abs(l) - point = self.scale(la if flip else l) - if la < self.min_lightness and c < self.min_colorfulness: # pragma: no cover - return True - else: - c2, h2 = self.spline(point)[1:] - if flip: - h2 = (h2 - 180) % 360 - diff = c2 - c - hdiff = abs(h % 360 - h2) - if hdiff > 180: # pragma: no cover - hdiff = 360 - hdiff - return ( - ((diff >= 0 and diff < self.threshold_upper) or (diff < 0 and abs(diff) < self.threshold_lower)) and - (c2 < 1e-5 or hdiff < 0.01) - ) diff --git a/lib/coloraide/spaces/cam16.py b/lib/coloraide/spaces/cam16.py deleted file mode 100644 index 3e5a917..0000000 --- a/lib/coloraide/spaces/cam16.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -CAM16 class. - -https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf -https://observablehq.com/@jrus/cam16 -https://arxiv.org/abs/1802.06067 -https://doi.org/10.1002/col.22131 -""" -import math -from .cam16_jmh import CAM16JMh -from ..spaces import Space, Labish -from ..cat import WHITES -from ..channels import Channel, FLG_MIRROR_PERCENT -from .. import util -from ..types import Vector -from .. import algebra as alg - - -def cam16_jmh_to_cam16_jab(jmh: Vector) -> Vector: - """Translate a CAM16 JMh to Jab of the same viewing conditions.""" - - J, M, h = jmh - return [ - J, - M * math.cos(math.radians(h)), - M * math.sin(math.radians(h)) - ] - - -def cam16_jab_to_cam16_jmh(jab: Vector) -> Vector: - """Translate a CAM16 Jab to JMh of the same viewing conditions.""" - - J, a, b = jab - M = math.sqrt(a ** 2 + b ** 2) - h = math.degrees(math.atan2(b, a)) - - return [J, M, util.constrain_hue(h)] - - -class CAM16(Labish, Space): - """CAM16 class (Jab).""" - - BASE = "cam16-jmh" - NAME = "cam16" - SERIALIZE = ("--cam16",) - CHANNELS = ( - Channel("j", 0.0, 100.0, limit=(0.0, None)), - Channel("a", -90.0, 90.0, flags=FLG_MIRROR_PERCENT), - Channel("b", -90.0, 90.0, flags=FLG_MIRROR_PERCENT) - ) - CHANNEL_ALIASES = { - "lightness": "j" - } - WHITE = WHITES['2deg']['D65'] - # Use the same environment as CAM16JMh - ENV = CAM16JMh.ENV - ACHROMATIC = CAM16JMh.ACHROMATIC - - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index in (1, 2): - if not math.isnan(coords[index]): - return coords[index] - - return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1] - - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value - - def is_achromatic(self, coords: Vector) -> bool: - """Check if color is achromatic.""" - - m, h = alg.rect_to_polar(coords[1], coords[2]) - return coords[0] == 0.0 or self.ACHROMATIC.test(coords[0], m, h) - - def to_base(self, coords: Vector) -> Vector: - """To CAM16 JMh from CAM16.""" - - return cam16_jab_to_cam16_jmh(coords) - - def from_base(self, coords: Vector) -> Vector: - """From CAM16 JMh to CAM16.""" - - return cam16_jmh_to_cam16_jab(coords) diff --git a/lib/coloraide/spaces/cam16_jmh.py b/lib/coloraide/spaces/cam16_jmh.py index fd63ac7..c1f2879 100644 --- a/lib/coloraide/spaces/cam16_jmh.py +++ b/lib/coloraide/spaces/cam16_jmh.py @@ -1,31 +1,25 @@ """ CAM16 class (JMh). -https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf +https://www.researchgate.net/publication/318152296_Comprehensive_color_solutions_CAM16_CAT16_and_CAM16-UCS +https://www.researchgate.net/publication/220865484_Usage_guidelines_for_CIECAM97s +https://doi.org/10.1002/col.22131 https://observablehq.com/@jrus/cam16 https://arxiv.org/abs/1802.06067 -https://doi.org/10.1002/col.22131 """ +from __future__ import annotations import math import bisect from .. import util from .. import algebra as alg from ..spaces import Space, LChish -from ..cat import WHITES +from ..cat import WHITES, CAT16 from ..channels import Channel, FLG_ANGLE -from .achromatic import Achromatic as _Achromatic -from .srgb_linear import lin_srgb_to_xyz -from .srgb import lin_srgb +from .lch import ACHROMATIC_THRESHOLD from ..types import Vector, VectorLike -from typing import List, Tuple, Any, Optional # CAT16 -M16 = [ - [0.401288, 0.650173, -0.051461], - [-0.250268, 1.204414, 0.045854], - [-0.002079, 0.048952, 0.953127] -] - +M16 = CAT16.MATRIX MI6_INV = alg.inv(M16) M1 = [ @@ -50,172 +44,6 @@ "H": (0.0, 100.0, 200.0, 300.0, 400.0) } -ACHROMATIC_RESPONSE = [ - [0.5197637353103578, 0.10742101902546781, 209.53702017876432], - [0.7653410752577902, 0.14285252204838345, 209.53699586564701], - [0.9597225272459006, 0.1674639325209385, 209.5369779985614], - [1.1268800051398011, 0.18685008110835863, 209.5369633468102], - [1.2763267654963262, 0.2030639541700499, 209.53695070009815], - [1.8790377054651906, 0.26061543207726645, 209.53690294574977], - [2.3559753209404573, 0.2998668559412108, 209.53686786031403], - [2.7660324716814633, 0.3305058430743193, 209.536839093849], - [3.1325805945142164, 0.3559891904115732, 209.53681426762876], - [3.4678031173571573, 0.3779903818931045, 209.5367921887862], - [3.7790286254197216, 0.397459500662546, 209.53677216077287], - [4.071081036208187, 0.41499276041595823, 209.53675373607373], - [4.35987940988476, 0.4317047383578854, 209.53673583657556], - [4.653923368359191, 0.44814375233486375, 209.53671791170922], - [7.833618587426542, 0.5996289140723937, 209.53653852944092], - [11.379933581738413, 0.7341977581705729, 209.53635903741286], - [15.226965773377026, 0.857259036676519, 209.53617955384075], - [19.331425543357224, 0.9717687075452716, 209.53600014936998], - [23.662223781347393, 1.0795574256327514, 209.5358208710024], - [28.195712492409086, 1.181856050998015, 209.5356417521982], - [32.91316356733729, 1.2795417603936137, 209.53546281794857], - [37.799294675334956, 1.3732674055098613, 209.53528408756415], - [42.841343691205466, 1.4635354939944947, 209.5351055763844], - [48.028455187972774, 1.5507433232034882, 209.53492729681835], - [53.35125605180922, 1.6352119585004592, 209.53474925909882], - [58.80155160494701, 1.7172056265103606, 209.53457147176655], - [64.37210171522501, 1.7969451471063127, 209.53439394202456], - [70.05645182832635, 1.8746175094579138, 209.53421667601953], - [75.8488028092011, 1.95038286970608, 209.53403967901312], - [81.74390888832981, 2.0243797747219405, 209.5338629555596], - [87.73699639870503, 2.096729134902292, 209.53368650960735], - [93.82369818202439, 2.1675372954936383, 209.53351034459058], - [100.0, 2.2368984457705743, 209.53333446353417], - [106.26219627903436, 2.304896533525034, 209.53315886906827], - [112.60685320678104, 2.37160680430044, 209.5329835635175], - [119.03077768851209, 2.4370970520495017, 209.53280854892373], - [125.53099102420309, 2.501428645093027, 209.53263382708093], - [132.1047064258276, 2.5646573751376196, 209.532459399577], - [138.74930968646498, 2.626834165511834, 209.53228526779787], - [145.46234245739691, 2.6880056663241145, 209.5321114329661], - [152.24148769946316, 2.748214758003532, 209.5319378961533], - [159.08455695969053, 2.807500980008346, 209.53176465830455], - [165.98947919010237, 2.865900897958051, 209.53159172021918], - [172.95429087732893, 2.92344841973748, 209.53141908260915], - [179.9771272925688, 2.980175069050048, 209.53124674607443], - [187.05621470411134, 3.0361102232687336, 209.5310747111302], - [194.18986342088985, 3.0912813211607015, 209.53090297820904], - [201.3764615567875, 3.145714045061113, 209.5307315476577], - [208.6144694227416, 3.199432481261756, 209.53056041976922], - [215.90241446789472, 3.252459261745779, 209.5303895947692], - [223.23888670275286, 3.304815689873112, 209.53021907281482], - [230.62253454702395, 3.3565218522006455, 209.53004885402189], - [238.05206105290907, 3.4075967182791853, 209.5298789384579], - [245.52622046139462, 3.458058229985187, 209.52970932613115], - [253.04381505480683, 3.507923381705505, 209.52954001701696], - [260.6036922737095, 3.5572082925096393, 209.52937101105175], - [268.20474207032214, 3.605928271271525, 209.52920230813686], - [275.8458944741247, 3.654097875574637, 209.52903390813748], - [283.52611734829725, 3.7017309651178785, 209.52886581088453], - [291.2444143182101, 3.7488407502396903, 209.52869801618388], - [298.9998228553806, 3.795439836103318, 209.52853052381926], - [306.7914125022303, 3.841540263013781, 209.52836333353144], - [314.61828322461565, 3.8871535432701356, 209.52819644505152], - [322.479563880563, 3.9322906949207233, 209.52802985809194], - [330.37441079487513, 3.9769622727358835, 209.52786357233052], - [338.30200643038665, 4.021178396670769, 209.52769758743355], - [346.2615581476034, 4.064948778071774, 209.527531903052], - [354.2522970453064, 4.108282743840221, 209.52736651881136], - [362.2734768754491, 4.151189258746347, 209.52720143432805], - [370.32437302633326, 4.193676946068329, 209.52703664919864], - [378.40428156863675, 4.235754106704258, 209.52687216301322], - [386.5125183593773, 4.2774287369029045, 209.52670797533614], - [394.648418199364, 4.318708544722146, 209.5265440857335], - [402.8113340400954, 4.359600965341976, 209.52638049373465], - [411.00063623642933, 4.400113175309797, 209.52621719889768], - [419.2157118416773, 4.440252105831943, 209.52605420073294], - [427.45596394207547, 4.480024455165197, 209.52589149876238], - [435.7208110278373, 4.519436700202084, 209.52572909248678], - [444.00968639824373, 4.558495107300929, 209.52556698141063], - [452.32203759843054, 4.597205742430075, 209.52540516500954], - [460.65732588572826, 4.635574480668486, 209.52524364277534], - [469.01502572358646, 4.67360701512636, 209.52508241417655], - [477.39462430127537, 4.711308865315819, 209.52492147868603], - [485.79562107768834, 4.7486853850221635, 209.5247608357571], - [494.2175273477135, 4.78574176970701, 209.5246004848463], - [502.65986582975273, 4.822483063478878, 209.52444042540557], - [511.12217027307815, 4.858914165665327, 209.52428065686505], - [519.603985083808, 4.895039837005332, 209.52412117867837], - [528.1048649683819, 4.930864705501955, 209.52396199027478], - [536.6243745934894, 4.966393271948785, 209.52380309107085], - [545.1620882614892, 5.00162991514904, 209.52364448050218], - [553.7175896004151, 5.036578896863506, 209.52348615798303], - [562.2904712677357, 5.071244366487241, 209.52332812293528], - [570.8803346670916, 5.105630365484719, 209.52317037476547], - [579.4867896772784, 5.139740831594337, 209.5230129128839], - [588.1094543928118, 5.173579602815829, 209.5228557367024], - [596.7479548754266, 5.207150421200013, 209.52269884562236], - [605.4019249159434, 5.240456936444621, 209.52254223903395], - [614.0710058059254, 5.2735027093139655, 209.5223859163477], - [622.7548461186354, 5.306291214893902, 209.52222987695998], - [631.4531014987923, 5.338825845688801, 209.52207412025294], - [640.1654344606808, 5.371109914568736, 209.52191864562857], - [648.8915141941957, 5.403146657580616, 209.5217634524664], - [657.6310163784137, 5.434939236625219, 209.52160854017026], - [666.3836230023236, 5.466490742017722, 209.52145390811216], - [675.1490221923619, 5.497804194921784, 209.5212995556808], - [683.9269080464271, 5.528882549682189, 209.52114548225762], - [692.7169804740557, 5.559728696047723, 209.52099168723132], - [701.5189450424705, 5.590345461302892, 209.5208381699748], - [710.3325128282281, 5.620735612298451, 209.52068492987357], - [719.1574002741951, 5.6509018574012, 209.52053196630135], - [727.9933290516212, 5.6808468483554915, 209.52037927864566], - [736.8400259270659, 5.710573182071535, 209.52022686627654], - [745.6972226339658, 5.7400834023332274, 209.5200747285655], - [754.5646557486314, 5.769380001437249, 209.51992286490193], - [763.4420665704857, 5.79846542176912, 209.51977127465065], - [772.3292010063437, 5.827342057308235, 209.519619957194] -] # type: List[Vector] - - -class Achromatic(_Achromatic): - """ - Test if color is achromatic. - - Should work quite well through the SDR range. Can reasonably handle HDR range out to 3 - which is far enough for anything practical. - We use a spline mainly to quickly fit the line in a way we do not have to analyze and tune. - """ - - L_IDX = 0 - C_IDX = 1 - H_IDX = 2 - - def __init__( - self, - data: Optional[List[Vector]] = None, - threshold_upper: float = 0.0, - threshold_lower: float = 0.0, - threshold_cutoff: float = alg.inf, - spline: str = 'linear', - mirror: bool = False, - *, - env: 'Environment', - **kwargs: Any - ) -> None: - """Initialize.""" - - super().__init__(data, threshold_upper, threshold_lower, threshold_cutoff, spline, mirror, env=env, **kwargs) - - def calc_achromatic_response( # type: ignore[override] - self, - parameters: List[Tuple[int, int, int, float]], - *, - env: 'Environment', - **kwargs: Any - ) -> None: # pragma: no cover - """Precalculate the achromatic response.""" - - super().calc_achromatic_response(parameters, env=env, **kwargs) - - def convert(self, coords: Vector, *, env: 'Environment', **kwargs: Any) -> Vector: # type: ignore[override] - """Convert to the target color space.""" - - return xyz_d65_to_cam16_jmh(lin_srgb_to_xyz(lin_srgb(coords)), env) - def hue_quadrature(h: float) -> float: """ @@ -254,7 +82,7 @@ def adapt(coords: Vector, fl: float) -> Vector: adapted = [] for c in coords: - x = alg.npow(fl * c * 0.01, ADAPTED_COEF) + x = (fl * abs(c) * 0.01) ** ADAPTED_COEF adapted.append(400 * math.copysign(x, c) / (x + 27.13)) return adapted @@ -266,7 +94,7 @@ def unadapt(adapted: Vector, fl: float) -> Vector: constant = 100 / fl * (27.13 ** ADAPTED_COEF_INV) for c in adapted: cabs = abs(c) - coords.append(math.copysign(constant * alg.npow(cabs / (400 - cabs), ADAPTED_COEF_INV), c)) + coords.append(math.copysign(constant * alg.spow(cabs / (400 - cabs), ADAPTED_COEF_INV), c)) return coords @@ -275,16 +103,17 @@ class Environment: Class to calculate and contain any required environmental data (viewing conditions included). Usage Guidelines for CIECAM97s (Nathan Moroney) - https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf + https://www.researchgate.net/publication/220865484_Usage_guidelines_for_CIECAM97s - ref_white: The reference white XYZ. We assume XYZ is in the range 0 - 1 as that is how ColorAide - handles XYZ everywhere else. It will be scaled up to 0 - 100. + white: This is the (x, y) chromaticity points for the white point. This should be the same + value as set in the color class `WHITE` value. adapting_luminance: This is the the luminance of the adapting field. The units are in cd/m2. The equation is `L = (E * R) / π`, where `E` is the illuminance in lux, `R` is the reflectance, and `L` is the luminance. If we assume a perfectly reflecting diffuser, `R` is assumed as 1. For the "gray world" assumption, we must also divide by 5 (or multiply by 0.2 - 20%). - This results in `La = E / π * 0.2`. + This results in `La = E / π * 0.2`. You can also ignore this gray world assumption converting + lux directly to nits (cd/m2) `lux / π`. background_luminance: The background is the region immediately surrounding the stimulus and for images is the neighboring portion of the image. Generally, this value is set to a value of 20. @@ -303,7 +132,8 @@ class Environment: def __init__( self, - ref_white: VectorLike, + *, + white: VectorLike, adapting_luminance: float, background_luminance: float, surround: str, @@ -317,19 +147,19 @@ def __init__( """ self.discounting = discounting - self.ref_white = util.xy_to_xyz(ref_white) + self.ref_white = util.xy_to_xyz(white) self.surround = surround - xyz_w = alg.multiply(self.ref_white, 100, dims=alg.D1_SC) # The average luminance of the environment in `cd/m^2cd/m` (a.k.a. nits) self.la = adapting_luminance # The relative luminance of the nearby background self.yb = background_luminance # Absolute luminance of the reference white. + xyz_w = util.scale100(self.ref_white) yw = xyz_w[1] # Cone response for reference white - rgb_w = alg.dot(M16, xyz_w, dims=alg.D2_D1) + rgb_w = alg.matmul(M16, xyz_w, dims=alg.D2_D1) # Surround: dark, dim, and average f, self.c, self.nc = SURROUND[self.surround] @@ -358,14 +188,14 @@ def __init__( def cam16_to_xyz_d65( - J: Optional[float] = None, - C: Optional[float] = None, - h: Optional[float] = None, - s: Optional[float] = None, - Q: Optional[float] = None, - M: Optional[float] = None, - H: Optional[float] = None, - env: Optional[Environment] = None + J: float | None = None, + C: float | None = None, + h: float | None = None, + s: float | None = None, + Q: float | None = None, + M: float | None = None, + H: float | None = None, + env: Environment | None = None ) -> Vector: """ From CAM16 to XYZ. @@ -378,8 +208,6 @@ def cam16_to_xyz_d65( """ # These check ensure one, and only one attribute for a given category is provided. - # Unfortunately, `mypy` is not smart enough to tell which one is not `None`, - # so we have to we must test each again later, but it is still faster than calling `cast()`. if not ((J is not None) ^ (Q is not None)): raise ValueError("Conversion requires one and only one: 'J' or 'Q'") @@ -422,37 +250,33 @@ def cam16_to_xyz_d65( alpha = (M / env.fl_root) / J_root elif s is not None: alpha = 0.0004 * (s ** 2) * (env.a_w + 4) / env.c - t = alg.npow(alpha * math.pow(1.64 - math.pow(0.29, env.n), -0.73), 10 / 9) + t = alg.spow(alpha * math.pow(1.64 - math.pow(0.29, env.n), -0.73), 10 / 9) # Eccentricity et = 0.25 * (math.cos(h_rad + 2) + 3.8) # Achromatic response - A = env.a_w * alg.npow(J_root, 2 / env.c / env.z) + A = env.a_w * alg.spow(J_root, 2 / env.c / env.z) # Calculate red-green and yellow-blue components p1 = 5e4 / 13 * env.nc * env.ncb * et p2 = A / env.nbb - r = 23 * (p2 + 0.305) * t / (23 * p1 + t * (11 * cos_h + 108 * sin_h)) + r = 23 * (p2 + 0.305) * alg.zdiv(t, 23 * p1 + t * (11 * cos_h + 108 * sin_h)) a = r * cos_h b = r * sin_h # Calculate back from cone response to XYZ - rgb_c = unadapt(alg.multiply(alg.dot(M1, [p2, a, b], dims=alg.D2_D1), 1 / 1403, dims=alg.D1_SC), env.fl) - return alg.divide( - alg.dot(MI6_INV, alg.multiply(rgb_c, env.d_rgb_inv, dims=alg.D1), dims=alg.D2_D1), - 100, - dims=alg.D1_SC - ) + rgb_c = unadapt(alg.multiply(alg.matmul(M1, [p2, a, b], dims=alg.D2_D1), 1 / 1403, dims=alg.D1_SC), env.fl) + return util.scale1(alg.matmul(MI6_INV, alg.multiply(rgb_c, env.d_rgb_inv, dims=alg.D1), dims=alg.D2_D1)) -def xyz_d65_to_cam16(xyzd65: Vector, env: Environment) -> Vector: +def xyz_d65_to_cam16(xyzd65: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector: """From XYZ to CAM16.""" # Cone response rgb_a = adapt( alg.multiply( - alg.dot(M16, alg.multiply(xyzd65, 100, dims=alg.D1_SC), dims=alg.D2_D1), + alg.matmul(M16, util.scale100(xyzd65), dims=alg.D2_D1), env.d_rgb, dims=alg.D1 ), @@ -462,24 +286,24 @@ def xyz_d65_to_cam16(xyzd65: Vector, env: Environment) -> Vector: # Calculate hue from red-green and yellow-blue components a = rgb_a[0] + (-12 * rgb_a[1] + rgb_a[2]) / 11 b = (rgb_a[0] + rgb_a[1] - 2 * rgb_a[2]) / 9 - h_rad = math.atan2(b, a) % alg.tau + h_rad = math.atan2(b, a) % math.tau # Eccentricity et = 0.25 * (math.cos(h_rad + 2) + 3.8) t = ( - 5e4 / 13 * env.nc * env.ncb * et * math.sqrt(a ** 2 + b ** 2) / - (rgb_a[0] + rgb_a[1] + 1.05 * rgb_a[2] + 0.305) + 5e4 / 13 * env.nc * env.ncb * + alg.zdiv(et * math.sqrt(a ** 2 + b ** 2), rgb_a[0] + rgb_a[1] + 1.05 * rgb_a[2] + 0.305) ) - alpha = alg.npow(t, 0.9) * math.pow(1.64 - math.pow(0.29, env.n), 0.73) + alpha = alg.spow(t, 0.9) * math.pow(1.64 - math.pow(0.29, env.n), 0.73) # Achromatic response A = env.nbb * (2 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2]) - J_root = alg.npow(A / env.a_w, 0.5 * env.c * env.z) + J_root = alg.spow(A / env.a_w, 0.5 * env.c * env.z) # Lightness - J = 100 * alg.npow(J_root, 2) + J = 100 * alg.spow(J_root, 2) # Brightness Q = (4 / env.c * J_root * (env.a_w + 4) * env.fl_root) @@ -494,7 +318,7 @@ def xyz_d65_to_cam16(xyzd65: Vector, env: Environment) -> Vector: h = util.constrain_hue(math.degrees(h_rad)) # Hue quadrature - H = hue_quadrature(h) + H = hue_quadrature(h) if calc_hue_quadrature else alg.NaN # Saturation s = 50 * alg.nth_root(env.c * alpha / (env.a_w + 4), 2) @@ -507,7 +331,7 @@ def xyz_d65_to_cam16_jmh(xyzd65: Vector, env: Environment) -> Vector: cam16 = xyz_d65_to_cam16(xyzd65, env) J, M, h = cam16[0], cam16[5], cam16[2] - return [max(0.0, J), max(0.0, M), h] + return [J, M, h] def cam16_jmh_to_xyz_d65(jmh: Vector, env: Environment) -> Vector: @@ -529,47 +353,39 @@ class CAM16JMh(LChish, Space): "hue": 'h' } WHITE = WHITES['2deg']['D65'] - # Assuming sRGB which has a lux of 64 - ENV = Environment(WHITE, 64 / math.pi * 0.2, 20, 'average', False) - # If discounting were True, we could remove the dynamic achromatic response - ACHROMATIC = Achromatic( - # Precalculated from: - # [ - # (1, 5, 1, 1000.0), - # (1, 10, 1, 200.0), - # (5, 521, 5, 100.0) - # ], - ACHROMATIC_RESPONSE, - 0.0012, - 0.0141, - 6.7, - 'catrom', - env=ENV + # Assuming sRGB which has a lux of 64: `((E * R) / PI) / 5` where `R = 1`. + ENV = Environment( + # Our white point. + white=WHITE, + # Assuming sRGB which has a lux of 64: `((E * R) / PI)` where `R = 1`. + # Divided by 5 (or multiplied by 20%) assuming gray world. + adapting_luminance=64 / math.pi * 0.2, + # Gray world assumption, 20% of reference white's `Yw = 100`. + background_luminance=20, + # Average surround + surround='average', + # Do not discount illuminant + discounting=False ) CHANNELS = ( - Channel("j", 0.0, 100.0, limit=(0.0, None)), - Channel("m", 0, 105.0, limit=(0.0, None)), - Channel("h", 0.0, 360.0, flags=FLG_ANGLE, nans=ACHROMATIC.hue) + Channel("j", 0.0, 100.0), + Channel("m", 0, 105.0), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index == 2: - h = coords[2] - return self.ACHROMATIC.get_ideal_hue(coords[0]) if math.isnan(h) else h - - elif index == 1: - c = coords[1] - return self.ACHROMATIC.get_ideal_chroma(coords[0]) if math.isnan(c) else c + def normalize(self, coords: Vector) -> Vector: + """Normalize.""" - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value + if coords[1] < 0.0: + return self.from_base(self.to_base(coords)) + coords[2] %= 360.0 + return coords - def is_achromatic(self, coords: Vector) -> bool: + def is_achromatic(self, coords: Vector) -> bool | None: """Check if color is achromatic.""" - return coords[0] == 0.0 or self.ACHROMATIC.test(coords[0], coords[1], coords[2]) + # Account for both positive and negative chroma + return coords[0] == 0 or abs(coords[1]) < ACHROMATIC_THRESHOLD def to_base(self, coords: Vector) -> Vector: """From CAM16 JMh to XYZ.""" diff --git a/lib/coloraide/spaces/cam16_ucs.py b/lib/coloraide/spaces/cam16_ucs.py index 806408c..d583085 100644 --- a/lib/coloraide/spaces/cam16_ucs.py +++ b/lib/coloraide/spaces/cam16_ucs.py @@ -5,11 +5,15 @@ https://arxiv.org/abs/1802.06067 https://doi.org/10.1002/col.22131 """ +from __future__ import annotations import math -from . cam16 import CAM16 -from ..types import Vector +from .cam16_jmh import CAM16JMh, xyz_d65_to_cam16, cam16_to_xyz_d65, Environment +from ..spaces import Space, Labish +from .lch import ACHROMATIC_THRESHOLD +from ..cat import WHITES +from .. import util from ..channels import Channel, FLG_MIRROR_PERCENT -from .. import algebra as alg +from ..types import Vector COEFFICENTS = { 'lcd': (0.77, 0.007, 0.0053), @@ -18,7 +22,7 @@ } -def cam16_to_cam16_ucs(jab: Vector, model: str) -> Vector: +def cam16_jmh_to_cam16_ucs(jmh: Vector, model: str, env: Environment) -> Vector: """ CAM16 (Jab) to CAM16 UCS (Jab). @@ -26,26 +30,34 @@ def cam16_to_cam16_ucs(jab: Vector, model: str) -> Vector: and then adding the new adjusted multiplier. Then we can just adjust lightness. """ - J, a, b = jab - M = math.sqrt(a ** 2 + b ** 2) + J, M, h = jmh + + if J == 0.0: + return [0.0, 0.0, 0.0] + + # Account for negative colorfulness by reconverting + if M < 0: + cam16 = xyz_d65_to_cam16(cam16_to_xyz_d65(J=J, M=M, h=h, env=env), env=env) + J, M, h = cam16[0], cam16[5], cam16[2] c1, c2 = COEFFICENTS[model][1:] - if M != 0: - a /= M - b /= M - M = math.log(1 + c2 * M) / c2 - a *= M - b *= M + # Only in extreme cases (outside the visible spectrum) + # can the input value for log become negative. + # Avoid domain error by forcing zero. + M = math.log(max(1 + c2 * M, 1.0)) / c2 + a = M * math.cos(math.radians(h)) + b = M * math.sin(math.radians(h)) + absj = abs(J) return [ - (1 + 100 * c1) * J / (1 + c1 * J), + math.copysign((1 + 100 * c1) * absj / (1 + c1 * absj), J), a, b ] -def cam16_ucs_to_cam16(ucs: Vector, model: str) -> Vector: +def cam16_ucs_to_cam16_jmh(ucs: Vector, model: str) -> Vector: """ CAM16 UCS (Jab) to CAM16 (Jab). @@ -54,53 +66,58 @@ def cam16_ucs_to_cam16(ucs: Vector, model: str) -> Vector: """ J, a, b = ucs - M = math.sqrt(a ** 2 + b ** 2) + + if J == 0.0: + return [0.0, 0.0, 0.0] c1, c2 = COEFFICENTS[model][1:] - if M != 0: - a /= M - b /= M - M = (math.exp(M * c2) - 1) / c2 - a *= M - b *= M + M = math.sqrt(a ** 2 + b ** 2) + M = (math.exp(M * c2) - 1) / c2 + h = math.degrees(math.atan2(b, a)) + absj = abs(J) return [ - J / (1 - c1 * (J - 100)), - a, - b + math.copysign(absj / (1 - c1 * (absj - 100)), J), + M, + util.constrain_hue(h) ] -class CAM16UCS(CAM16): +class CAM16UCS(Labish, Space): """CAM16 UCS (Jab) class.""" - BASE = "cam16" + BASE = "cam16-jmh" NAME = "cam16-ucs" SERIALIZE = ("--cam16-ucs",) MODEL = 'ucs' CHANNELS = ( - Channel("j", 0.0, 100.0, limit=(0.0, None)), + Channel("j", 0.0, 100.0), Channel("a", -50.0, 50.0, flags=FLG_MIRROR_PERCENT), Channel("b", -50.0, 50.0, flags=FLG_MIRROR_PERCENT) ) + CHANNEL_ALIASES = { + "lightness": "j" + } + WHITE = WHITES['2deg']['D65'] + # Use the same environment as CAM16JMh + ENV = CAM16JMh.ENV def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" - j, a, b = cam16_ucs_to_cam16(coords, self.MODEL) - m, h = alg.rect_to_polar(a, b) - return coords[0] == 0.0 or self.ACHROMATIC.test(j, m, h) + j, m = cam16_ucs_to_cam16_jmh(coords, self.MODEL)[:-1] + return j == 0 or abs(m) < ACHROMATIC_THRESHOLD def to_base(self, coords: Vector) -> Vector: - """To XYZ from CAM16.""" + """To CAM16 JMh from CAM16.""" - return cam16_ucs_to_cam16(coords, self.MODEL) + return cam16_ucs_to_cam16_jmh(coords, self.MODEL) def from_base(self, coords: Vector) -> Vector: - """From XYZ to CAM16.""" + """From CAM16 JMh to CAM16.""" - return cam16_to_cam16_ucs(coords, self.MODEL) + return cam16_jmh_to_cam16_ucs(coords, self.MODEL, self.ENV) class CAM16LCD(CAM16UCS): @@ -110,7 +127,7 @@ class CAM16LCD(CAM16UCS): SERIALIZE = ("--cam16-lcd",) MODEL = 'lcd' CHANNELS = ( - Channel("j", 0.0, 100.0, limit=(0.0, None)), + Channel("j", 0.0, 100.0), Channel("a", -70.0, 70.0, flags=FLG_MIRROR_PERCENT), Channel("b", -70.0, 70.0, flags=FLG_MIRROR_PERCENT) ) @@ -123,7 +140,7 @@ class CAM16SCD(CAM16UCS): SERIALIZE = ("--cam16-scd",) MODEL = 'scd' CHANNELS = ( - Channel("j", 0.0, 100.0, limit=(0.0, None)), + Channel("j", 0.0, 100.0), Channel("a", -40.0, 40.0, flags=FLG_MIRROR_PERCENT), Channel("b", -40.0, 40.0, flags=FLG_MIRROR_PERCENT) ) diff --git a/lib/coloraide/spaces/cmy.py b/lib/coloraide/spaces/cmy.py index 563e546..0101502 100644 --- a/lib/coloraide/spaces/cmy.py +++ b/lib/coloraide/spaces/cmy.py @@ -1,9 +1,9 @@ """Uncalibrated, naive CMY color space.""" -from ..spaces import Space +from __future__ import annotations +from ..spaces import Regular, Space from ..channels import Channel from ..cat import WHITES from ..types import Vector -from typing import Tuple from .. import algebra as alg import math @@ -20,12 +20,12 @@ def cmy_to_srgb(cmy: Vector) -> Vector: return [1 - c for c in cmy] -class CMY(Space): +class CMY(Regular, Space): """The CMY color class.""" BASE = "srgb" NAME = "cmy" - SERIALIZE = ("--cmy",) # type: Tuple[str, ...] + SERIALIZE = ("--cmy",) # type: tuple[str, ...] CHANNELS = ( Channel("c", 0.0, 1.0, bound=True), Channel("m", 0.0, 1.0, bound=True), @@ -43,7 +43,7 @@ def is_achromatic(self, coords: Vector) -> bool: black = [1, 1, 1] for x in alg.vcross(coords, black): - if not alg.isclose(0.0, x, abs_tol=1e-4, dims=algs.SC): + if not math.isclose(0.0, x, abs_tol=1e-4): return False return True diff --git a/lib/coloraide/spaces/cmyk.py b/lib/coloraide/spaces/cmyk.py index 1cdd150..066f6b7 100644 --- a/lib/coloraide/spaces/cmyk.py +++ b/lib/coloraide/spaces/cmyk.py @@ -3,11 +3,11 @@ https://www.w3.org/TR/css-color-5/#cmyk-rgb """ +from __future__ import annotations from ..spaces import Space from ..channels import Channel from ..cat import WHITES from ..types import Vector -from typing import Tuple from .. import algebra as alg import math @@ -42,7 +42,7 @@ class CMYK(Space): BASE = "srgb" NAME = "cmyk" - SERIALIZE = ("--cmyk",) # type: Tuple[str, ...] + SERIALIZE = ("--cmyk",) # type: tuple[str, ...] CHANNELS = ( Channel("c", 0.0, 1.0, bound=True), Channel("m", 0.0, 1.0, bound=True), @@ -60,12 +60,12 @@ class CMYK(Space): def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" - if alg.isclose(1.0, coords[-1], abs_tol=1e-4, dims=alg.SC): + if math.isclose(1.0, coords[-1], abs_tol=1e-4): return True black = [1, 1, 1] for x in alg.vcross(coords[:-1], black): - if not alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): + if not math.isclose(0.0, x, abs_tol=1e-5): return False return True diff --git a/lib/coloraide/spaces/cubehelix.py b/lib/coloraide/spaces/cubehelix.py index be5de32..ad0a300 100644 --- a/lib/coloraide/spaces/cubehelix.py +++ b/lib/coloraide/spaces/cubehelix.py @@ -23,6 +23,7 @@ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. """ +from __future__ import annotations from ..spaces import Space, HSLish from ..cat import WHITES from ..channels import Channel, FLG_ANGLE @@ -78,7 +79,7 @@ class Cubehelix(HSLish, Space): NAME = "cubehelix" SERIALIZE = ("--cubehelix",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, MAX_SAT, bound=True), Channel("l", 0.0, 1.0, bound=True) ) @@ -88,6 +89,16 @@ class Cubehelix(HSLish, Space): "lightness": "l" } WHITE = WHITES['2deg']['D65'] + GAMUT_CHECK = 'srgb' + + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + coords[1] *= -1.0 + coords[0] += 180.0 + coords[0] %= 360.0 + return coords def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" diff --git a/lib/coloraide/spaces/din99o.py b/lib/coloraide/spaces/din99o.py index 5874277..722748a 100644 --- a/lib/coloraide/spaces/din99o.py +++ b/lib/coloraide/spaces/din99o.py @@ -3,6 +3,8 @@ https://de.wikipedia.org/wiki/DIN99-Farbraum """ +from __future__ import annotations +import sys from ..cat import WHITES from .lab import Lab import math @@ -32,14 +34,14 @@ C2 = 0.0039 C3 = 0.075 C4 = 0.0435 +MIN_FLOAT = sys.float_info.min def lab_to_din99o(lab: Vector) -> Vector: """XYZ to DIN99o.""" l, a, b = lab - val = 1 + abs(C2 * l) - l99o = C1 * math.copysign(1, l) * math.log(val) / KE + l99o = C1 * math.log(max(1 + C2 * l, MIN_FLOAT)) / KE if a == 0 and b == 0: a99o = b99o = 0.0 @@ -47,8 +49,7 @@ def lab_to_din99o(lab: Vector) -> Vector: eo = a * math.cos(RADS) + b * math.sin(RADS) fo = FACTOR * (b * math.cos(RADS) - a * math.sin(RADS)) go = math.sqrt(eo ** 2 + fo ** 2) - val = 1 + C3 * go - c99o = math.log(val) / (C4 * KE * KCH) + c99o = math.log(max(1 + C3 * go, MIN_FLOAT)) / (C4 * KE * KCH) h99o = math.atan2(fo, eo) + RADS a99o = c99o * math.cos(h99o) @@ -81,7 +82,7 @@ def din99o_to_lab(din99o: Vector) -> Vector: f = g * math.sin(h99o - RADS) return [ - math.copysign(1, l99o) * (math.exp((abs(l99o) * KE) / C1) - 1) / C2, + (math.exp(l99o * KE / C1) - 1) / C2, e * math.cos(RADS) - (f / FACTOR) * math.sin(RADS), e * math.sin(RADS) + (f / FACTOR) * math.cos(RADS) ] diff --git a/lib/coloraide/spaces/display_p3.py b/lib/coloraide/spaces/display_p3.py index b0cb3c3..42fe41b 100644 --- a/lib/coloraide/spaces/display_p3.py +++ b/lib/coloraide/spaces/display_p3.py @@ -1,15 +1,20 @@ """Display-p3 color class.""" -from ..cat import WHITES -from .srgb import sRGB, lin_srgb, gam_srgb +from __future__ import annotations +from .srgb_linear import sRGBLinear +from .srgb import lin_srgb, gam_srgb from ..types import Vector -class DisplayP3(sRGB): +class DisplayP3(sRGBLinear): """Display-p3 class.""" BASE = "display-p3-linear" NAME = "display-p3" - WHITE = WHITES['2deg']['D65'] + + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE def to_base(self, coords: Vector) -> Vector: """To XYZ from Display P3.""" diff --git a/lib/coloraide/spaces/display_p3_linear.py b/lib/coloraide/spaces/display_p3_linear.py index 2af6e38..90f1679 100644 --- a/lib/coloraide/spaces/display_p3_linear.py +++ b/lib/coloraide/spaces/display_p3_linear.py @@ -1,6 +1,6 @@ """Linear Display-p3 color class.""" -from ..cat import WHITES -from .srgb import sRGB +from __future__ import annotations +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -25,22 +25,21 @@ def lin_p3_to_xyz(rgb: Vector) -> Vector: """ # 0 was computed as -3.972075516933488e-17 - return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_p3(xyz: Vector) -> Vector: """Convert XYZ to linear-light P3.""" - return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) -class DisplayP3Linear(sRGB): +class DisplayP3Linear(sRGBLinear): """Linear Display-p3 class.""" BASE = "xyz-d65" NAME = "display-p3-linear" SERIALIZE = ('--display-p3-linear',) - WHITE = WHITES['2deg']['D65'] def to_base(self, coords: Vector) -> Vector: """To XYZ from Linear Display P3.""" diff --git a/lib/coloraide/spaces/hct.py b/lib/coloraide/spaces/hct.py index a312cde..c86d2c5 100644 --- a/lib/coloraide/spaces/hct.py +++ b/lib/coloraide/spaces/hct.py @@ -6,12 +6,12 @@ Environment settings are calculated with the assumption of L* 50. As ColorAide usually cares about setting powerless hues as NaN, especially for good interpolation, -we've also calculated the cut off for chromatic colors and will properly enforce achromatic,powerless +we've also calculated the cut off for chromatic colors and will properly enforce achromatic, powerless hues. This is because CAM16 actually resolves colors as achromatic before chroma reaches zero as lightness increases. In the SDR range, a Tone of 100 will have a cut off as high as ~2.87 chroma. -Generally, the HCT color space is restricted to SDR range in the Material library, but we do not have -such restrictions. +Generally, the HCT color space is restricted to sRGB and SDR range in the Material library, but we do +not have such restrictions. Though we did not port HCT from Material Color Utilities, we did test against it, and are pretty much on point. The only differences are due to matrix precision and white point precision. Material @@ -40,113 +40,46 @@ color(--hct 256.79 31.766 33.344 / 1) ``` -Differences are inconsequential. """ +from __future__ import annotations from .. import algebra as alg -from .. import util from ..spaces import Space, LChish from ..cat import WHITES from ..channels import Channel, FLG_ANGLE from .cam16_jmh import Environment, cam16_to_xyz_d65, xyz_d65_to_cam16 -from .cam16_jmh import Achromatic as _Achromatic -from .srgb_linear import lin_srgb_to_xyz -from .srgb import lin_srgb from .lab import EPSILON, KAPPA, KE -from ..types import Vector, VectorLike -from typing import Any, List, Tuple +from .lch import ACHROMATIC_THRESHOLD +from ..types import Vector import math -ACHROMATIC_RESPONSE = [ - [0.06991457401674239, 0.14894004649491988, 209.54685746644427], - [0.13982914803348123, 0.19600973408107872, 209.54683151375164], - [0.20974372205022362, 0.22847588408233888, 209.54681244250324], - [0.279658296066966, 0.2539510473857674, 209.54679680373425], - [0.34957287008370486, 0.27520353123074354, 209.5467833054029], - [0.6991457401674133, 0.3503388633000292, 209.54673233806605], - [1.0487186102511181, 0.40138459372769575, 209.54669489478562], - [1.398291480334823, 0.44114839784486665, 209.54666419687067], - [1.7478643504185314, 0.47417655352389076, 209.54663770502296], - [2.0974372205022362, 0.5026634742613167, 209.54661414586045], - [2.447010090585941, 0.5278524887930993, 209.54659277583056], - [2.7965829606696495, 0.5505226723840699, 209.54657311722647], - [3.1624547958278697, 0.5721193279495385, 209.54655401960494], - [3.5553195764898433, 0.5933528830664249, 209.5465348955287], - [3.9752712774319967, 0.6142212187430165, 209.54651576862292], - [4.422818762249047, 0.6347462660662626, 209.54649663922487], - [4.89845719045681, 0.6549477974516252, 209.54647750762507], - [5.40266895987223, 0.6748437111130419, 209.54645837410766], - [5.93592454797767, 0.6944502696114526, 209.54643923893252], - [6.4986832666748775, 0.7137823011126716, 209.5464201023271], - [7.091393942308237, 0.7328533701575941, 209.54640096452033], - [7.714495530830426, 0.7516759233486977, 209.54638182570906], - [8.362902589291522, 0.7702614142702453, 209.54636268608778], - [9.010442756551821, 0.7886204111202876, 209.54634354582498], - [9.653817900540862, 0.8067626898669606, 209.5463244050959], - [10.29318377392433, 0.82469731522517, 209.5463052640513], - [10.928685772528809, 0.8424327113314979, 209.5462861228275], - [11.56045990779819, 0.859976723665597, 209.54626698158054], - [12.188633662809302, 0.8773366735026917, 209.5462478404262], - [12.813326748632115, 0.8945194059620538, 209.54622869949029], - [13.434651775012771, 0.9115313325460387, 209.54620955888788], - [14.0527148470809, 0.9283784689196849, 209.54619041872456], - [14.667616097925809, 0.9450664685622591, 209.54617127911737], - [15.279450165363095, 0.9616006528304665, 209.54615214014538], - [15.888306619956744, 0.977986037883613, 209.54613300191994], - [16.494270350320917, 0.9942273588674444, 209.54611386451924], - [17.097421910858294, 1.0103290916839158, 209.54609472803028], - [17.697837836366574, 1.026295472637739, 209.5460755925407], - [18.295590927334914, 1.042130516206454, 209.54605645811773], - [18.890750509238096, 1.0578380311468378, 209.54603732484748], - [19.483382668700443, 1.0734216351263122, 209.54601819278727], - [20.073550469030984, 1.088884768038463, 209.54599906201673], - [20.661314147315657, 1.1042307041468111, 209.54597993259077], - [53.38896474111432, 1.8932913045660376, 209.54481718319346], - [100.0, 2.871588955286566, 209.54293597883415], - [142.21233355267947, 3.6598615904431204, 209.54109066792404], - [181.74498695762142, 4.333037532847627, 209.53928262365196], - [219.37955914284302, 4.924275900757143, 209.5375118768831], - [255.55949550426857, 5.452264905961894, 209.53577795810992], - [290.56862469201246, 5.929002120236851, 209.534080169679], - [324.6031878228979, 6.362852583853973, 209.5324177017366], - [357.8063946931686, 6.7599912702266085, 209.53078968985596], - [390.2870215828255, 7.125170758147506, 209.52919524657287], - [422.13028305191517, 7.462166817890066, 209.52763347977915] -] # type: List[Vector] - - -def y_to_lstar(y: float, white: VectorLike) -> float: + +def y_to_lstar(y: float) -> float: """Convert XYZ Y to Lab L*.""" - y = y / white[1] fy = alg.nth_root(y, 3) if y > EPSILON else (KAPPA * y + 16) / 116 return (116.0 * fy) - 16.0 -def lstar_to_y(lstar: float, white: VectorLike) -> float: +def lstar_to_y(lstar: float) -> float: """Convert Lab L* to XYZ Y.""" fy = (lstar + 16) / 116 y = fy ** 3 if lstar > KE else lstar / KAPPA - return y * white[1] + return y def hct_to_xyz(coords: Vector, env: Environment) -> Vector: """ Convert HCT to XYZ. - Use Newton Raphson method to try and converge as quick as possible or - converge as close as we can. If we don't converge in about 7 iterations, - we will instead correct the Y in XYZ and re-calculate the J. This will - incrementally get our J closer. If we do not converge, we will do a final - round with Newton Raphson one last time with a more accurate J. - - If, for whatever reason, we cannot achieve the accuracy we seek in the - allotted iterations, just return the closest we were able to get. + Use Newton's method to try and converge as quick as possible or converge as + close as we can. While the requested precision is achieved most of the time, + it may not always be achievable. Especially past the visible spectrum, the + algorithm will likely struggle to get the same precision. If, for whatever + reason, we cannot achieve the accuracy we seek in the allotted iterations, + just return the closest we were able to get. """ - # Threshold of how close is close enough - threshold = 2e-8 - h, c, t = coords[:] # Shortcut out for black @@ -154,23 +87,29 @@ def hct_to_xyz(coords: Vector, env: Environment) -> Vector: return [0.0, 0.0, 0.0] # Calculate the Y we need to target - y = lstar_to_y(t, env.ref_white) + y = lstar_to_y(t) # Try to start with a reasonable initial guess for J - if c < 142: - # Calculated by curve fitting J vs T. Works well with colors within a mid-sized gamut, but not ultra wide. - j = 0.00462403 * t ** 2 + 0.51460278 * t + 2.62845677 + # Calculated by curve fitting J vs T. + if t > 0: + j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233 else: - # For ultra wide gamuts we can get a better J by correcting Y in XYZ and then calculating our J - xyz = cam16_to_xyz_d65(J=t, C=c, h=h, env=env) - xyz[1] = y - j = xyz_d65_to_cam16(xyz, env)[0] + j = 9.514440756550361e-06 * t ** 2 + 0.08693057439788597 * t -21.928975842194614 + + # Threshold of how close is close enough, and max number of attempts. + # More precision and more attempts means more time spent iterating. + # Higher required precision gives more accuracy but also increases the + # chance of not hitting the goal. 2e-12 allows us to convert round trip + # with reasonable accuracy of six decimal places or more. + threshold = 2e-12 + max_attempt = 15 - # Try to find a J such that the returned y matches the returned y of the L* attempt = 0 - last = alg.inf + last = math.inf best = j - while attempt < 16: + + # Try to find a J such that the returned y matches the returned y of the L* + while attempt <= max_attempt: xyz = cam16_to_xyz_d65(J=j, C=c, h=h, env=env) # If we are within range, return XYZ @@ -182,21 +121,14 @@ def hct_to_xyz(coords: Vector, env: Environment) -> Vector: best = j last = delta - # Use Newton Raphson method to see if we can quickly converge (or get as close as we can) - if (attempt < 7 or attempt >= 13) and xyz[1] != 0: - # ``` - # f(j_root) = (j ** (1 / 2)) * 0.1 - # f(j) = ((f(j_root) * 100) ** 2) / j - 1 = 0 - # f(j_root) = Y = y / 100 - # f(j) = (y ** 2) / j - 1 - # f'(j) = (2 * y) / j - # ``` - j = j - (xyz[1] - y) * j / (2 * xyz[1]) - - # Correct the lightness in XYZ and then re-calculate J - else: - xyz[1] = y - j = xyz_d65_to_cam16(xyz, env)[0] + # ``` + # f(j_root) = (j ** (1 / 2)) * 0.1 + # f(j) = ((f(j_root) * 100) ** 2) / j - 1 = 0 + # f(j_root) = Y = y / 100 + # f(j) = (y ** 2) / j - 1 + # f'(j) = (2 * y) / j + # ``` + j = j - (xyz[1] - y) * j / (2 * xyz[1]) attempt += 1 @@ -207,23 +139,11 @@ def hct_to_xyz(coords: Vector, env: Environment) -> Vector: def xyz_to_hct(coords: Vector, env: Environment) -> Vector: """Convert XYZ to HCT.""" - t = y_to_lstar(coords[1], env.ref_white) + t = y_to_lstar(coords[1]) + if t == 0.0: + return [0.0, 0.0, 0.0] c, h = xyz_d65_to_cam16(coords, env)[1:3] - return [h, max(0.0, c), max(0.0, t)] - - -class Achromatic(_Achromatic): - """Test HCT achromatic response.""" - - # Lightness and chroma (equivalent) index. - L_IDX = 2 - C_IDX = 1 - H_IDX = 0 - - def convert(self, coords: Vector, *, env: Environment, **kwargs: Any) -> Vector: # type: ignore[override] - """Convert to the target color space.""" - - return xyz_to_hct(lin_srgb_to_xyz(lin_srgb(coords)), env) + return [h, c, t] class HCT(LChish, Space): @@ -234,11 +154,16 @@ class HCT(LChish, Space): SERIALIZE = ("--hct",) WHITE = WHITES['2deg']['D65'] ENV = Environment( - WHITE, - 200 / math.pi * lstar_to_y(50.0, util.xy_to_xyz(WHITE)), - lstar_to_y(50.0, util.xy_to_xyz(WHITE)) * 100, - 'average', - False + # D65 white point. + white=WHITE, + # 200 lux or `~11.72 cd/m2` multiplied by ~18.42%, a variation of gray world assumption. + adapting_luminance=200 / math.pi * lstar_to_y(50.0), + # A variation on gray world assumption: ~18.42% of reference white's `Yw == 100`. + background_luminance=lstar_to_y(50.0) * 100, + # Average surround. + surround='average', + # No discounting of illuminant. + discounting=False ) CHANNEL_ALIASES = { "lightness": "t", @@ -247,48 +172,27 @@ class HCT(LChish, Space): "hue": "h" } - # Achromatic detection - # Precalculated from: - # [ - # (1, 5, 1, 1000.0), - # (1, 40, 1, 200.0), - # (50, 551, 50, 100.0) - # ] - ACHROMATIC = Achromatic( - ACHROMATIC_RESPONSE, - 0.0097, - 0.0787, - 8.1, - 'catrom', - env=ENV, - ) - CHANNELS = ( Channel("h", 0.0, 360.0, flags=FLG_ANGLE), - Channel("c", 0.0, 145.0, limit=(0.0, None)), - Channel("t", 0.0, 100.0, limit=(0.0, None)) + Channel("c", 0.0, 145.0), + Channel("t", 0.0, 100.0) ) - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index == 0: - h = coords[0] - return self.ACHROMATIC.get_ideal_hue(coords[2]) if math.isnan(h) else h - - elif index == 1: - c = coords[1] - return self.ACHROMATIC.get_ideal_chroma(coords[0]) if math.isnan(c) else c + def normalize(self, coords: Vector) -> Vector: + """Normalize.""" - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value + if coords[1] < 0.0: + return self.from_base(self.to_base(coords)) + coords[0] %= 360.0 + return coords - def is_achromatic(self, coords: Vector) -> bool: + def is_achromatic(self, coords: Vector) -> bool | None: """Check if color is achromatic.""" - return coords[2] == 0.0 or self.ACHROMATIC.test(coords[2], coords[1], coords[0]) + # Account for both positive and negative chroma + return coords[2] == 0 or abs(coords[1]) < ACHROMATIC_THRESHOLD - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return LCh-ish names in the order L C h.""" channels = self.channels diff --git a/lib/coloraide/spaces/hpluv.py b/lib/coloraide/spaces/hpluv.py index 157c0fb..c002928 100644 --- a/lib/coloraide/spaces/hpluv.py +++ b/lib/coloraide/spaces/hpluv.py @@ -24,24 +24,25 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from ..spaces import Space, HSLish from ..cat import WHITES from ..channels import Channel, FLG_ANGLE from .lab import EPSILON, KAPPA from .srgb_linear import XYZ_TO_RGB import math +from .. import algebra as alg from .. import util from ..types import Vector -from typing import Tuple, List -def distance_line_from_origin(line: Tuple[float, float]) -> float: +def distance_line_from_origin(line: tuple[float, float]) -> float: """Distance line from origin.""" return abs(line[1]) / math.sqrt(line[0] ** 2 + 1) -def get_bounds(l: float) -> List[Tuple[float, float]]: +def get_bounds(l: float) -> list[tuple[float, float]]: """Get bounds.""" result = [] @@ -70,7 +71,7 @@ def max_safe_chroma_for_l(l: float) -> float: return min(distance_line_from_origin(bound) for bound in get_bounds(l)) -def hpluv_to_lch(hpluv: Vector) -> Vector: +def hpluv_to_luv(hpluv: Vector) -> Vector: """Convert HPLuv to LCh.""" h, s, l = hpluv @@ -81,14 +82,16 @@ def hpluv_to_lch(hpluv: Vector) -> Vector: l = 0.0 else: _hx_max = max_safe_chroma_for_l(l) - c = _hx_max / 100 * s - return [l, c, util.constrain_hue(h)] + c = _hx_max * 0.01 * s + a, b = alg.polar_to_rect(c, h) + return [l, a, b] -def lch_to_hpluv(lch: Vector) -> Vector: +def luv_to_hpluv(luv: Vector) -> Vector: """Convert LCh to HPLuv.""" - l, c, h = lch + l = luv[0] + c, h = alg.rect_to_polar(luv[1], luv[2]) s = 0.0 if l > 100 - 1e-7: l = 100 @@ -103,11 +106,11 @@ def lch_to_hpluv(lch: Vector) -> Vector: class HPLuv(HSLish, Space): """HPLuv class.""" - BASE = 'lchuv' + BASE = 'luv' NAME = "hpluv" SERIALIZE = ("--hpluv",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("p", 0.0, 100.0, bound=True), Channel("l", 0.0, 100.0, bound=True) ) @@ -118,6 +121,14 @@ class HPLuv(HSLish, Space): } WHITE = WHITES['2deg']['D65'] + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + return self.from_base(self.to_base(coords)) + coords[0] %= 360.0 + return coords + def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" @@ -126,9 +137,14 @@ def is_achromatic(self, coords: Vector) -> bool: def to_base(self, coords: Vector) -> Vector: """To LChuv from HPLuv.""" - return hpluv_to_lch(coords) + return hpluv_to_luv(coords) def from_base(self, coords: Vector) -> Vector: """From LChuv to HPLuv.""" - return lch_to_hpluv(coords) + return luv_to_hpluv(coords) + + def radial_name(self) -> str: + """Radial name.""" + + return "p" diff --git a/lib/coloraide/spaces/hsi.py b/lib/coloraide/spaces/hsi.py index 12f95d2..af224e9 100644 --- a/lib/coloraide/spaces/hsi.py +++ b/lib/coloraide/spaces/hsi.py @@ -3,6 +3,7 @@ https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation """ +from __future__ import annotations import math from .hsv import HSV from ..cat import WHITES @@ -45,15 +46,7 @@ def hsi_to_srgb(hsi: Vector) -> Vector: x = c * z if math.isnan(h): # pragma: no cover - # In our current setup, this will not occur. If colors are naturally achromatic, - # they will resolve to zeros automatically even without this check. This case - # would be a shortcut normally. - # - # Unnatural cases, such as explicitly setting of hue to undefined, could cause this, - # but the conversion pipeline converts all undefined values to zero. We'd have to - # encounter a natural case due to conversion to or from HSI mid pipeline to trigger - # this, and we are not currently in a position where that would occur with sRGB being - # the only pass-through. + # NaN values are resolved before this point, so this will never execute. rgb = [0.0] * 3 elif 0 <= h <= 1: rgb = [c, x, 0] @@ -79,7 +72,7 @@ class HSI(HSV): NAME = "hsi" SERIALIZE = ("--hsi",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, 1.0, bound=True), Channel("i", 0.0, 1.0, bound=True) ) @@ -90,6 +83,7 @@ class HSI(HSV): } WHITE = WHITES['2deg']['D65'] GAMUT_CHECK = "srgb" + CLIP_SPACE = None def to_base(self, coords: Vector) -> Vector: """To sRGB from HSI.""" diff --git a/lib/coloraide/spaces/hsl/__init__.py b/lib/coloraide/spaces/hsl/__init__.py index b9fc60d..0cb4f1d 100644 --- a/lib/coloraide/spaces/hsl/__init__.py +++ b/lib/coloraide/spaces/hsl/__init__.py @@ -1,7 +1,8 @@ """HSL class.""" -from ...spaces import Space, HSLish +from __future__ import annotations +from ...spaces import HSLish, Space from ...cat import WHITES -from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT +from ...channels import Channel, FLG_ANGLE from ... import util from ...types import Vector @@ -27,6 +28,11 @@ def srgb_to_hsl(rgb: Vector) -> Vector: s = 0 if l == 0.0 or l == 1.0 else (mx - l) / min(l, 1 - l) h *= 60.0 + # Adjust for negative saturation + if s < 0: + s *= -1.0 + h += 180.0 + return [util.constrain_hue(h), s, l] @@ -56,9 +62,9 @@ class HSL(HSLish, Space): NAME = "hsl" SERIALIZE = ("--hsl",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), - Channel("s", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT), - Channel("l", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT) + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), + Channel("s", 0.0, 1.0, bound=True), + Channel("l", 0.0, 1.0, bound=True) ) CHANNEL_ALIASES = { "hue": "h", @@ -66,9 +72,19 @@ class HSL(HSLish, Space): "lightness": "l" } WHITE = WHITES['2deg']['D65'] - GAMUT_CHECK = "srgb" + GAMUT_CHECK = "srgb" # type: str | None + CLIP_SPACE = "hsl" # type: str | None + + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + coords[1] *= -1.0 + coords[0] += 180.0 + coords[0] %= 360.0 + return coords - def is_achromatic(self, coords: Vector) -> bool: + def is_achromatic(self, coords: Vector) -> bool | None: """Check if color is achromatic.""" return abs(coords[1]) < 1e-4 or coords[2] == 0.0 or abs(1 - coords[2]) < 1e-7 diff --git a/lib/coloraide/spaces/hsl/css.py b/lib/coloraide/spaces/hsl/css.py index 8b6d157..6281d2d 100644 --- a/lib/coloraide/spaces/hsl/css.py +++ b/lib/coloraide/spaces/hsl/css.py @@ -1,9 +1,10 @@ """HSL class.""" +from __future__ import annotations from .. import hsl as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color @@ -14,19 +15,30 @@ class HSL(base.HSL): def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = True, + percent: bool | Sequence[bool] | None = None, comma: bool = False, **kwargs: Any ) -> str: """Convert to CSS.""" + if percent is None: + if not color: + percent = True + else: + percent = False + elif isinstance(percent, bool): + if comma: + percent = True + elif comma: + percent = [False, True, True] + list(percent[3:4]) + return serialize.serialize_css( parent, func='hsl', @@ -36,7 +48,7 @@ def to_string( none=none, color=color, legacy=comma, - percent=True if comma else percent, + percent=percent, scale=100 ) @@ -45,7 +57,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/hsluv.py b/lib/coloraide/spaces/hsluv.py index e5fe331..b8c259e 100644 --- a/lib/coloraide/spaces/hsluv.py +++ b/lib/coloraide/spaces/hsluv.py @@ -24,24 +24,25 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from ..spaces import Space, HSLish from ..cat import WHITES from ..channels import Channel, FLG_ANGLE from .lab import EPSILON, KAPPA from .srgb_linear import XYZ_TO_RGB import math +from .. import algebra as alg from .. import util from ..types import Vector -from typing import List, Dict -def length_of_ray_until_intersect(theta: float, line: Dict[str, float]) -> float: +def length_of_ray_until_intersect(theta: float, line: dict[str, float]) -> float: """Length of ray until intersect.""" return line['intercept'] / (math.sin(theta) - line['slope'] * math.cos(theta)) -def get_bounds(l: float) -> List[Dict[str, float]]: +def get_bounds(l: float) -> list[dict[str, float]]: """Get bounds.""" result = [] @@ -72,7 +73,7 @@ def max_chroma_for_lh(l: float, h: float) -> float: return min(length for length in lengths if length >= 0) -def hsluv_to_lch(hsluv: Vector) -> Vector: +def hsluv_to_luv(hsluv: Vector) -> Vector: """Convert HSLuv to LCh.""" h, s, l = hsluv @@ -83,14 +84,17 @@ def hsluv_to_lch(hsluv: Vector) -> Vector: l = 0.0 else: _hx_max = max_chroma_for_lh(l, h) - c = _hx_max / 100.0 * s - return [l, c, util.constrain_hue(h)] + c = _hx_max * 0.01 * s + a, b = alg.polar_to_rect(c, h) + return [l, a, b] -def lch_to_hsluv(lch: Vector) -> Vector: + +def luv_to_hsluv(luv: Vector) -> Vector: """Convert LCh to HSLuv.""" - l, c, h = lch + l = luv[0] + c, h = alg.rect_to_polar(luv[1], luv[2]) s = 0.0 if l > 100 - 1e-7: l = 100.0 @@ -105,11 +109,11 @@ def lch_to_hsluv(lch: Vector) -> Vector: class HSLuv(HSLish, Space): """HSLuv class.""" - BASE = 'lchuv' + BASE = 'luv' NAME = "hsluv" SERIALIZE = ("--hsluv",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, 100.0, bound=True), Channel("l", 0.0, 100.0, bound=True) ) @@ -120,6 +124,15 @@ class HSLuv(HSLish, Space): } WHITE = WHITES['2deg']['D65'] GAMUT_CHECK = "srgb" + CLIP_SPACE = "hsluv" + + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + return self.from_base(self.to_base(coords)) + coords[0] %= 360.0 + return coords def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" @@ -129,9 +142,9 @@ def is_achromatic(self, coords: Vector) -> bool: def to_base(self, coords: Vector) -> Vector: """To LChuv from HSLuv.""" - return hsluv_to_lch(coords) + return hsluv_to_luv(coords) def from_base(self, coords: Vector) -> Vector: """From LChuv to HSLuv.""" - return lch_to_hsluv(coords) + return luv_to_hsluv(coords) diff --git a/lib/coloraide/spaces/hsv.py b/lib/coloraide/spaces/hsv.py index fc330b3..440276d 100644 --- a/lib/coloraide/spaces/hsv.py +++ b/lib/coloraide/spaces/hsv.py @@ -1,12 +1,14 @@ """HSV class.""" +from __future__ import annotations from ..spaces import Space, HSVish +from .hsl import srgb_to_hsl, hsl_to_srgb from ..cat import WHITES from ..channels import Channel, FLG_ANGLE from .. import util from ..types import Vector -def hsv_to_hsl(hsv: Vector) -> Vector: +def hsv_to_srgb(hsv: Vector) -> Vector: """ HSV to HSL. @@ -17,18 +19,17 @@ def hsv_to_hsl(hsv: Vector) -> Vector: l = v * (1.0 - s / 2.0) s = 0.0 if l == 0.0 or l == 1.0 else (v - l) / min(l, 1.0 - l) - return [util.constrain_hue(h), s, l] + return hsl_to_srgb([h, s, l]) -def hsl_to_hsv(hsl: Vector) -> Vector: +def srgb_to_hsv(srgb: Vector) -> Vector: """ HSL to HSV. https://en.wikipedia.org/wiki/HSL_and_HSV#Interconversion """ - h, s, l = hsl - + h, s, l = srgb_to_hsl(srgb) v = l + s * min(l, 1.0 - l) s = 0.0 if v == 0.0 else 2 * (1.0 - l / v) @@ -38,11 +39,11 @@ def hsl_to_hsv(hsl: Vector) -> Vector: class HSV(HSVish, Space): """HSL class.""" - BASE = "hsl" + BASE = "srgb" NAME = "hsv" SERIALIZE = ("--hsv",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, 1.0, bound=True), Channel("v", 0.0, 1.0, bound=True) ) @@ -51,9 +52,18 @@ class HSV(HSVish, Space): "saturation": "s", "value": "v" } - GAMUT_CHECK = "srgb" + GAMUT_CHECK = "srgb" # type: str | None + CLIP_SPACE = "hsv" # type: str | None WHITE = WHITES['2deg']['D65'] + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + return self.from_base(self.to_base(coords)) + coords[0] %= 360.0 + return coords + def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" @@ -62,9 +72,9 @@ def is_achromatic(self, coords: Vector) -> bool: def to_base(self, coords: Vector) -> Vector: """To HSL from HSV.""" - return hsv_to_hsl(coords) + return hsv_to_srgb(coords) def from_base(self, coords: Vector) -> Vector: """From HSL to HSV.""" - return hsl_to_hsv(coords) + return srgb_to_hsv(coords) diff --git a/lib/coloraide/spaces/hunter_lab.py b/lib/coloraide/spaces/hunter_lab.py index e9a80cb..4bae3f5 100644 --- a/lib/coloraide/spaces/hunter_lab.py +++ b/lib/coloraide/spaces/hunter_lab.py @@ -3,6 +3,7 @@ https://support.hunterlab.com/hc/en-us/articles/203997095-Hunter-Lab-Color-Scale-an08-96a2 """ +from __future__ import annotations from ..cat import WHITES from ..spaces.lab import Lab from .. import algebra as alg @@ -41,11 +42,11 @@ def hlab_to_xyz(hlab: Vector, white: VectorLike) -> Vector: ka = CKA * alg.nth_root(xn / CXN, 2) kb = CKB * alg.nth_root(zn / CZN, 2) l, a, b = hlab - l /= 100 + l *= 0.01 y = (l ** 2) * yn x = (((a * l) / ka) + (y / yn)) * xn z = (((b * l) / kb) - (y / yn)) * -zn - return alg.divide([x, y, z], 100, dims=alg.D1_SC) + return alg.multiply([x, y, z], 0.01, dims=alg.D1_SC) class HunterLab(Lab): diff --git a/lib/coloraide/spaces/hwb/__init__.py b/lib/coloraide/spaces/hwb/__init__.py index 59d9b9b..978801d 100644 --- a/lib/coloraide/spaces/hwb/__init__.py +++ b/lib/coloraide/spaces/hwb/__init__.py @@ -1,8 +1,9 @@ """HWB class.""" +from __future__ import annotations from ...spaces import Space, HWBish from ..hsl import srgb_to_hsl, hsl_to_srgb from ...cat import WHITES -from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT +from ...channels import Channel, FLG_ANGLE from ...types import Vector @@ -28,9 +29,9 @@ class HWB(HWBish, Space): NAME = "hwb" SERIALIZE = ("--hwb",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), - Channel("w", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT), - Channel("b", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT) + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), + Channel("w", 0.0, 1.0, bound=True), + Channel("b", 0.0, 1.0, bound=True) ) CHANNEL_ALIASES = { "hue": "h", diff --git a/lib/coloraide/spaces/hwb/css.py b/lib/coloraide/spaces/hwb/css.py index f672316..eca92d9 100644 --- a/lib/coloraide/spaces/hwb/css.py +++ b/lib/coloraide/spaces/hwb/css.py @@ -1,9 +1,10 @@ """HWB class.""" +from __future__ import annotations from .. import hwb as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color @@ -14,18 +15,21 @@ class HWB(base.HWB): def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = True, + percent: bool | Sequence[bool] | None = None, **kwargs: Any ) -> str: """Convert to CSS.""" + if percent is None: + percent = False if color else True + return serialize.serialize_css( parent, func='hwb', @@ -43,7 +47,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/ictcp.py b/lib/coloraide/spaces/ictcp.py index 7ab70ba..a1b1d64 100644 --- a/lib/coloraide/spaces/ictcp.py +++ b/lib/coloraide/spaces/ictcp.py @@ -3,13 +3,13 @@ https://professional.dolby.com/siteassets/pdfs/ictcp_dolbywhitepaper_v071.pdf """ +from __future__ import annotations from .lab import Lab from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT from .. import util from .. import algebra as alg from ..types import Vector -from typing import Tuple # All PQ Values are equivalent to defaults as stated in link below: # https://en.wikipedia.org/wiki/High-dynamic-range_video#Perceptual_quantizer @@ -26,15 +26,15 @@ # XYZ transform matrices xyz_to_lms_m = [ - [0.359132, 0.697604, -0.03578], - [-0.19218800000000003, 1.1003800000000001, 0.07554], - [0.006956, 0.074916, 0.8433400000000001] + [0.3592832590121218, 0.6976051147779497, -0.0358915932320289], + [-0.19208084637049927, 1.1004767970374318, 0.07537486585191187], + [0.0070797844607477164, 0.07483966621863658, 0.8433265453898765] ] lms_to_xyz_mi = [ - [2.070508203420414, -1.32670394499891, 0.20668057903526466], - [0.3650251372337387, 0.6804585253538308, -0.04546355870112316], - [-0.04950397021841151, -0.049503970218411505, 1.1880952852418765] + [2.0701522183894223, -1.3263473389671556, 0.20665104762940512], + [0.36473852097480713, 0.6805660249472276, -0.04530454592203474], + [-0.04974720753581203, -0.04926096669661379, 1.1880659249923042] ] # LMS to Izazbz matrices @@ -50,37 +50,39 @@ [1.0, 0.5600313357106791, -0.32062717498731885] ] +YW = 203 + def ictcp_to_xyz_d65(ictcp: Vector) -> Vector: """From ICtCp to XYZ.""" # Convert to LMS prime - pqlms = alg.dot(ictcp_to_lms_p_mi, ictcp, dims=alg.D2_D1) + pqlms = alg.matmul(ictcp_to_lms_p_mi, ictcp, dims=alg.D2_D1) # Decode PQ LMS to LMS lms = util.pq_st2084_eotf(pqlms) # Convert back to absolute XYZ D65 - absxyz = alg.dot(lms_to_xyz_mi, lms, dims=alg.D2_D1) + absxyz = alg.matmul(lms_to_xyz_mi, lms, dims=alg.D2_D1) # Convert back to normal XYZ D65 - return util.absxyz_to_xyz(absxyz) + return util.absxyz_to_xyz(absxyz, YW) def xyz_d65_to_ictcp(xyzd65: Vector) -> Vector: """From XYZ to ICtCp.""" - # Convert from XYZ D65 to an absolute XYZ D5 - absxyz = util.xyz_to_absxyz(xyzd65) + # Convert from XYZ D65 to an absolute XYZ D65 + absxyz = util.xyz_to_absxyz(xyzd65, YW) # Convert to LMS - lms = alg.dot(xyz_to_lms_m, absxyz, dims=alg.D2_D1) + lms = alg.matmul(xyz_to_lms_m, absxyz, dims=alg.D2_D1) # PQ encode the LMS pqlms = util.pq_st2084_oetf(lms) # Calculate Izazbz - return alg.dot(lms_p_to_ictcp_m, pqlms, dims=alg.D2_D1) + return alg.matmul(lms_p_to_ictcp_m, pqlms, dims=alg.D2_D1) class ICtCp(Lab): @@ -88,11 +90,11 @@ class ICtCp(Lab): BASE = "xyz-d65" NAME = "ictcp" - SERIALIZE = ("--ictcp",) + SERIALIZE = ("ictcp", "--ictcp",) CHANNELS = ( Channel("i", 0.0, 1.0), - Channel("ct", -0.5, 0.5, flags=FLG_MIRROR_PERCENT), - Channel("cp", -0.5, 0.5, flags=FLG_MIRROR_PERCENT) + Channel("ct", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), + Channel("cp", -1.0, 1.0, flags=FLG_MIRROR_PERCENT) ) CHANNEL_ALIASES = { "intensity": "i", diff --git a/lib/coloraide/spaces/igpgtg.py b/lib/coloraide/spaces/igpgtg.py index de53d8d..06268b6 100644 --- a/lib/coloraide/spaces/igpgtg.py +++ b/lib/coloraide/spaces/igpgtg.py @@ -3,16 +3,12 @@ https://www.ingentaconnect.com/content/ist/jpi/2020/00000003/00000002/art00002# """ +from __future__ import annotations from .ipt import IPT from ..channels import Channel, FLG_MIRROR_PERCENT from ..cat import WHITES from .. import algebra as alg -from .achromatic import Achromatic as _Achromatic -from .srgb_linear import lin_srgb_to_xyz -from .srgb import lin_srgb from ..types import Vector -from typing import Tuple, Any -import math XYZ_TO_LMS = [ [2.968, 2.741, -0.649], @@ -38,50 +34,29 @@ [0.02265698651657832, -0.004701151874826367, -0.030048158824914562] ] -ACHROMATIC_RESPONSE = [ - [0.01710472400677632, 7.497407788263645e-05, 289.0071727628954], - [0.022996189520032607, 0.00010079777395973735, 289.00717276289754], - [0.027343043084773422, 0.00011985106810105086, 289.00717276288543], - [0.03091688192289772, 0.00013551605464416815, 289.0071727629022], - [0.9741484960046702, 0.004269924798539192, 289.00717276289504], - [5.049390603804086, 0.022132681254572965, 289.0071727628912] -] # type: List[Vector] - def xyz_to_igpgtg(xyz: Vector) -> Vector: """XYZ to IgPgTg.""" - lms_in = alg.dot(XYZ_TO_LMS, xyz, dims=alg.D2_D1) + lms_in = alg.matmul(XYZ_TO_LMS, xyz, dims=alg.D2_D1) lms = [ - alg.npow(lms_in[0] / 18.36, 0.427), - alg.npow(lms_in[1] / 21.46, 0.427), - alg.npow(lms_in[2] / 19435, 0.427) + alg.spow(lms_in[0] / 18.36, 0.427), + alg.spow(lms_in[1] / 21.46, 0.427), + alg.spow(lms_in[2] / 19435, 0.427) ] - return alg.dot(LMS_TO_IGPGTG, lms, dims=alg.D2_D1) + return alg.matmul(LMS_TO_IGPGTG, lms, dims=alg.D2_D1) def igpgtg_to_xyz(itp: Vector) -> Vector: """IgPgTg to XYZ.""" - lms = alg.dot(IGPGTG_TO_LMS, itp, dims=alg.D2_D1) + lms = alg.matmul(IGPGTG_TO_LMS, itp, dims=alg.D2_D1) lms_in = [ alg.nth_root(lms[0], 0.427) * 18.36, alg.nth_root(lms[1], 0.427) * 21.46, alg.nth_root(lms[2], 0.427) * 19435 ] - return alg.dot(LMS_TO_XYZ, lms_in, dims=alg.D2_D1) - - -class Achromatic(_Achromatic): - """Test if color is achromatic.""" - - def convert(self, coords: Vector, **kwargs: Any) -> Vector: - """Convert to the target color space.""" - - lab = xyz_to_igpgtg(lin_srgb_to_xyz(lin_srgb(coords))) - l = lab[0] - c, h = alg.rect_to_polar(*lab[1:]) - return [l, c, h] + return alg.matmul(LMS_TO_XYZ, lms_in, dims=alg.D2_D1) class IgPgTg(IPT): @@ -89,7 +64,7 @@ class IgPgTg(IPT): BASE = "xyz-d65" NAME = "igpgtg" - SERIALIZE = ("--igpgtg",) # type: Tuple[str, ...] + SERIALIZE = ("--igpgtg",) # type: tuple[str, ...] CHANNELS = ( Channel("ig", 0.0, 1.0), Channel("pg", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), @@ -101,32 +76,6 @@ class IgPgTg(IPT): "tritan": "tg" } WHITE = WHITES['2deg']['D65'] - # Precalculated from: - # [ - # (1, 5, 1, 1000.0), - # (100, 101, 1, 100), - # (520, 521, 1, 100) - # ] - ACHROMATIC = Achromatic( - ACHROMATIC_RESPONSE, - 1e-5, - 1e-5, - 0.03126, - 'linear', - mirror=True - ) - - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index in (1, 2): - if not math.isnan(coords[index]): - return coords[index] - - return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1] - - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/ipt.py b/lib/coloraide/spaces/ipt.py index 3262f4c..cd210b7 100644 --- a/lib/coloraide/spaces/ipt.py +++ b/lib/coloraide/spaces/ipt.py @@ -4,15 +4,11 @@ https://www.researchgate.net/publication/\ 221677980_Development_and_Testing_of_a_Color_Space_IPT_with_Improved_Hue_Uniformity. """ -from ..spaces import Space, Labish +from __future__ import annotations +from .lab import Lab from ..channels import Channel, FLG_MIRROR_PERCENT from .. import algebra as alg -from .achromatic import Achromatic as _Achromatic -from .srgb_linear import lin_srgb_to_xyz -from .srgb import lin_srgb from ..types import Vector -from typing import Any, List -import math from .. import util XYZ_TO_LMS = [ @@ -39,48 +35,27 @@ [1.0, 0.03261510991706641, -0.6768871830691794] ] -ACHROMATIC_RESPONSE = [ - [0.017066845239980113, 1.3218447776798768e-06, 329.76026731824635], - [0.022993026958471587, 1.7808336678784566e-06, 329.76026731797435], - [0.02737255832988907, 2.1200328924793134e-06, 329.7602673179663], - [0.03097697795223092, 2.3991989122003048e-06, 329.7602673180789], - [0.9999910919149724, 7.745034210925492e-05, 329.7602673179579], - [5.243613106559707, 0.0004061232467760781, 329.76026731814255] -] # type: List[Vector] - def xyz_to_ipt(xyz: Vector) -> Vector: """XYZ to IPT.""" - lms_p = [alg.npow(c, 0.43) for c in alg.dot(XYZ_TO_LMS, xyz, dims=alg.D2_D1)] - return alg.dot(LMS_P_TO_IPT, lms_p, dims=alg.D2_D1) + lms_p = [alg.spow(c, 0.43) for c in alg.matmul(XYZ_TO_LMS, xyz, dims=alg.D2_D1)] + return alg.matmul(LMS_P_TO_IPT, lms_p, dims=alg.D2_D1) def ipt_to_xyz(ipt: Vector) -> Vector: """IPT to XYZ.""" - lms = [alg.nth_root(c, 0.43) for c in alg.dot(IPT_TO_LMS_P, ipt, dims=alg.D2_D1)] - return alg.dot(LMS_TO_XYZ, lms, dims=alg.D2_D1) - - -class Achromatic(_Achromatic): - """Test achromatic response.""" - - def convert(self, coords: Vector, **kwargs: Any) -> Vector: - """Convert to the target color space.""" - - lab = xyz_to_ipt(lin_srgb_to_xyz(lin_srgb(coords))) - l = lab[0] - c, h = alg.rect_to_polar(*lab[1:]) - return [l, c, h] + lms = [alg.nth_root(c, 0.43) for c in alg.matmul(IPT_TO_LMS_P, ipt, dims=alg.D2_D1)] + return alg.matmul(LMS_TO_XYZ, lms, dims=alg.D2_D1) -class IPT(Labish, Space): +class IPT(Lab): """The IPT class.""" BASE = "xyz-d65" NAME = "ipt" - SERIALIZE = ("--ipt",) # type: Tuple[str, ...] + SERIALIZE = ("--ipt",) # type: tuple[str, ...] CHANNELS = ( Channel("i", 0.0, 1.0), Channel("p", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), @@ -96,38 +71,6 @@ class IPT(Labish, Space): # We use chromaticity points (0.31270, 0.3290) which gives us an XYZ of ~[0.9505, 1.0000, 1.0890] # IPT uses XYZ of [0.9504, 1.0, 1.0889] which yields chromaticity points ~(0.3127035830618893, 0.32902313032606195) WHITE = tuple(util.xyz_to_xyY([0.9504, 1.0, 1.0889])[:-1]) # type: ignore[assignment] - # Precalculated from: - # [ - # (1, 5, 1, 1000.0), - # (100, 101, 1, 100), - # (520, 521, 1, 100) - # ] - ACHROMATIC = Achromatic( - ACHROMATIC_RESPONSE, - 1e-5, - 1e-5, - 0.00049, - 'linear', - mirror=True - ) # type: _Achromatic - - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index in (1, 2): - if not math.isnan(coords[index]): - return coords[index] - - return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1] - - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value - - def is_achromatic(self, coords: Vector) -> bool: - """Check if color is achromatic.""" - - m, h = alg.rect_to_polar(coords[1], coords[2]) - return self.ACHROMATIC.test(coords[0], m, h) def to_base(self, coords: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/jzazbz.py b/lib/coloraide/spaces/jzazbz.py index a985a8d..95548a2 100644 --- a/lib/coloraide/spaces/jzazbz.py +++ b/lib/coloraide/spaces/jzazbz.py @@ -3,38 +3,26 @@ https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272 -There seems to be some debate on how to scale Jzazbz. Colour Science chooses not to scale at all. -Colorio seems to scale at 100. - -The spec mentions multiple times targeting a luminance of 10,000 cd/m^2. -Relative XYZ has Y=1 for media white -BT.2048 says media white Y=203 at PQ 58 +Relative XYZ has Y=100 for media white +BT.2048 says media white Y=203 at PQ 58, which is about 1000 cd/m^2. This is confirmed here: https://www.itu.int/dms_pub/itu-r/opb/rep/R-REP-BT.2408-3-2019-PDF-E.pdf -It is tough to tell who is correct as everything passes through the MATLAB scripts fine as it -just scales the results differently, so forward and backwards translation comes out great regardless, -but looking at the images in the spec, it seems the scaling using Y=203 at PQ 58 may be correct. It -is almost certain that some scaling is being applied and that applying none is almost certainly wrong. - If at some time that these assumptions are incorrect, we will be happy to alter the model. """ -from ..spaces import Space, Labish -from .achromatic import Achromatic as _Achromatic +from __future__ import annotations +from ..spaces import Space from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT from .. import util from .. import algebra as alg -from .lch import lab_to_lch -from .srgb_linear import lin_srgb_to_xyz -from .srgb import lin_srgb -from ..types import Vector -from typing import Any, List -import math +from ..types import Vector, Matrix # noqa: F401 +from .lab import Lab B = 1.15 G = 0.66 D = -0.56 D0 = 1.6295499532821566E-11 +YW = 203 # All PQ Values are equivalent to defaults as stated in link below except `M2` (and `IM2`): # https://en.wikipedia.org/wiki/High-dynamic-range_video#Perceptual_quantizer @@ -52,227 +40,107 @@ # XYZ transform matrices -xyz_to_lms_m = [ +XYZ_TO_LMS = [ [0.41478972, 0.579999, 0.014648], [-0.20151, 1.120649, 0.0531008], [-0.0166008, 0.2648, 0.6684799] ] -lms_to_xyz_mi = [ +LMS_TO_XYZ = [ [1.9242264357876069, -1.0047923125953657, 0.037651404030617994], [0.35031676209499907, 0.7264811939316552, -0.06538442294808501], [-0.09098281098284755, -0.31272829052307394, 1.5227665613052603] ] # LMS to Izazbz matrices -lms_p_to_izazbz_m = [ +LMS_P_TO_IZAZBZ = [ [0.5, 0.5, 0], [3.524, -4.066708, 0.542708], [0.199076, 1.096799, -1.295875] ] -izazbz_to_lms_p_mi = [ +IZAZBZ_TO_LMS_P = [ [1.0, 0.13860504327153927, 0.05804731615611883], [1.0, -0.1386050432715393, -0.058047316156118904], [1.0, -0.09601924202631895, -0.811891896056039] ] -ACHROMATIC_RESPONSE = [ - [0.0009185262133445958, 2.840443191466584e-06, 216.0885802021336], - [0.0032449260086909186, 8.875176418290735e-06, 216.0865538095895], - [0.007045724283550615, 1.742628198531671e-05, 216.08513073729978], - [0.009865500056099596, 2.317265885648471e-05, 216.08447255685712], - [0.012214842923826377, 2.768276363465363e-05, 216.0840428784794], - [0.014313828964294468, 3.15364404794859e-05, 216.08371809158197], - [0.016438077905865333, 3.5290049847676045e-05, 216.08343078937077], - [0.018605469297130712, 3.898508192957335e-05, 216.08317068540933], - [0.02080811172991238, 4.261478385537034e-05, 216.0829334034566], - [0.02303956968571666, 4.617490464976015e-05, 216.0827155404453], - [0.02529454916234039, 4.966297634911679e-05, 216.0825143871757], - [0.027568661881969103, 5.307781011104134e-05, 216.08232775391835], - [0.029858245862958505, 5.641913782115498e-05, 216.08215383175184], - [0.032160227098370596, 5.9687353969353895e-05, 216.0819911399305], - [0.03447201166859765, 6.288332788976944e-05, 216.08183842876093], - [0.03679140069552315, 6.600826589362147e-05, 216.08169464885202], - [0.03911652265347357, 6.906360946484213e-05, 216.0815588821907], - [0.04144577901905777, 7.20509595041043e-05, 216.08143037308045], - [0.04377780027866024, 7.497201997536904e-05, 216.0813084440367], - [0.046111410055380574, 7.78285557456021e-05, 216.08119250961198], - [0.04844559565682705, 8.062236105672717e-05, 216.08108206116256], - [0.050779483741710464, 8.335523609797642e-05, 216.08097664284261], - [0.053112320097915965, 8.602896956101964e-05, 216.0808758658446], - [0.05544345274603036, 8.864532585124454e-05, 216.08077937053], - [0.05777231775004377, 9.120603576758393e-05, 216.08068683882303], - [0.060098427245276025, 9.371278988327557e-05, 216.08059799668482], - [0.062421359292580254, 9.616723398809035e-05, 216.08051257202447], - [0.07282655496930118, 0.00010660656866708172, 216.08016488545363], - [0.09108704537148082, 0.00012302746725157782, 216.07966036150265], - [0.10900798276245852, 0.00013720664366179703, 216.0792568965177], - [0.12658714074774824, 0.00014958895848933898, 216.0789232434426], - [0.1438408486329413, 0.00016050730576011498, 216.07864044077417], - [0.16079190286441, 0.00017021501266814123, 216.07839616904852], - [0.17746453522326563, 0.0001789083001012923, 216.07818199543323], - [0.1938822908546569, 0.00018674170523656867, 216.07799190659145], - [0.21006717973109132, 0.00019383878540645526, 216.07782150011536], - [0.22603940395038172, 0.00020029967178728176, 216.0776674210859], - [0.24181734649813305, 0.0002062064957239789, 216.0775271074707], - [0.25741767436011453, 0.00021162735571841754, 216.0773985097046], - [0.27285548569428925, 0.00021661926443777217, 216.07728001861], - [0.28814446743495276, 0.00022123037188011426, 216.07717029782012], - [0.3032970476681226, 0.00022550166532677018, 216.07706827050953], - [0.318324536052124, 0.00022946828610769828, 216.07697303732877], - [0.3332372500029604, 0.00023316056105526304, 216.07688383235663], - [0.34804462653287077, 0.00023660481904069536, 216.0768000283236], - [0.3627553206321442, 0.00023982404450001245, 216.07672106548637], - [0.37737729148697624, 0.0002428384038912725, 216.07664646902424], - [0.3919178779258038, 0.0002456656744964799, 216.07657583096483], - [0.40638386443891256, 0.00024832159559521613, 216.07650879846213], - [0.420781539003173, 0.0002508201586917396, 216.07644505801895], - [0.4351167438075533, 0.0002531738482749366, 216.07638435201847], - [0.44939491983780117, 0.0002553938438855013, 216.0763264167306], - [0.46362114614988975, 0.00025749018901337296, 216.076271046612], - [0.47780017454650436, 0.000259471935031054, 216.0762180267072], - [0.49193646026919147, 0.0002613472624003357, 216.07616722683406], - [0.506034189231372, 0.0002631235853618668, 216.07611845640926], - [0.520097302241697, 0.0002648076406577341, 216.07607158130352], - [0.5341295166035784, 0.0002664055645672449, 216.07602649958457], - [0.5481343454215033, 0.0002679229595196272, 216.07598307638068], - [0.562115114898706, 0.00026936495156998765, 216.0759412038042], - [0.5760749798710889, 0.00027073624083088496, 216.0759007944157], - [0.5900169377887273, 0.0002720411453401863, 216.07586174613544], - [0.6039438413278134, 0.00027328363966826354, 216.07582400851473], - [0.6178584097918066, 0.0002744673887323733, 216.07578748393507], - [0.6317632394388409, 0.0002755957777534978, 216.07575212814703], - [0.6456608128558685, 0.00027667193897750657, 216.07571787420144], - [0.6595535074843613, 0.0002776987749361305, 216.0756845900282], - [0.6734436033881548, 0.00027867897881155425, 216.0756523336041], - [0.6873332903451649, 0.0002796150541276385, 216.07562100643182], - [0.7012246743322099, 0.0002805093307679912, 216.07559056780278], - [0.7151197834661946, 0.00028136397973813873, 216.07556096861626], - [0.7290205734555001, 0.00028218102717968054, 216.07553214064387], - [0.7429289326111091, 0.00028296236534187003, 216.07550410795596], - [0.7568466864597538, 0.0002837097645372413, 216.07547681413567], - [0.7707756019973733, 0.00028442488261536087, 216.0754501913429], - [0.7847173916174169, 0.0002851092732967129, 216.07542424437477], - [0.7986737167436694, 0.0002857643945778034, 216.07539893412002], - [0.8126461911946142, 0.0002863916163923197, 216.07537422001636], - [0.82663638430444, 0.0002869922264017996, 216.07535011510663], - [0.8406458238212122, 0.00028756743686351684, 216.07532657427606], - [0.8546759986026592, 0.0002881183896480139, 216.07530355785332], - [0.8687283611263583, 0.00028864616127277436, 216.07528106686505], - [0.8828043298308796, 0.0002891517676790176, 216.0752590661328], - [0.8969052913010638, 0.0002896361684484102, 216.07523755309595], - [0.9110326023119082, 0.0002901002704615418, 216.07521649953736], - [0.9251875917408455, 0.0002905449316470965, 216.07519588476185], - [0.939371562360337, 0.00029097096413252364, 216.0751757112678], - [0.9535857925200474, 0.0002913791373231644, 216.07515593963097], - [0.9678315377267531, 0.000291770180656392, 216.0751365593372] -] # type: List[Vector] - - -class Achromatic(_Achromatic): - """Test if color is achromatic.""" - - def convert(self, coords: Vector, **kwargs: Any) -> Vector: - """Convert to the target color space.""" - - return lab_to_lch(xyz_d65_to_jzazbz(lin_srgb_to_xyz(lin_srgb(coords)))) +def xyz_d65_to_izazbz(xyz: Vector, lms_matrix: Matrix, m2: float) -> Vector: + """Absolute XYZ to Izazbz.""" -def jzazbz_to_xyz_d65(jzazbz: Vector) -> Vector: - """From Jzazbz to XYZ.""" + xa, ya, za = xyz + xm = (B * xa) - ((B - 1) * za) + ym = (G * ya) - ((G - 1) * xa) - jz, az, bz = jzazbz + # Convert to LMS + lms = alg.matmul(XYZ_TO_LMS, [xm, ym, za], dims=alg.D2_D1) - # Calculate Iz - iz = (jz + D0) / (1 + D - D * (jz + D0)) + # PQ encode the LMS + pqlms = util.pq_st2084_oetf(lms, m2=m2) + + # Calculate Izazbz + return alg.matmul(lms_matrix, pqlms, dims=alg.D2_D1) + + +def izazbz_to_xyz_d65(izazbz: Vector, lms_matrix: Matrix, m2: float) -> Vector: + """Izazbz to absolute XYZ.""" # Convert to LMS prime - pqlms = alg.dot(izazbz_to_lms_p_mi, [iz, az, bz], dims=alg.D2_D1) + pqlms = alg.matmul(lms_matrix, izazbz, dims=alg.D2_D1) # Decode PQ LMS to LMS - lms = util.pq_st2084_eotf(pqlms, m2=M2) + lms = util.pq_st2084_eotf(pqlms, m2=m2) # Convert back to absolute XYZ D65 - xm, ym, za = alg.dot(lms_to_xyz_mi, lms, dims=alg.D2_D1) + xm, ym, za = alg.matmul(LMS_TO_XYZ, lms, dims=alg.D2_D1) xa = (xm + ((B - 1) * za)) / B ya = (ym + ((G - 1) * xa)) / G - # Convert back to normal XYZ D65 - return util.absxyz_to_xyz([xa, ya, za]) + return [xa, ya, za] -def xyz_d65_to_jzazbz(xyzd65: Vector) -> Vector: - """From XYZ to Jzazbz.""" +def jzazbz_to_xyz_d65(jzazbz: Vector) -> Vector: + """From Jzazbz to XYZ.""" - # Convert from XYZ D65 to an absolute XYZ D5 - xa, ya, za = util.xyz_to_absxyz(xyzd65) - xm = (B * xa) - ((B - 1) * za) - ym = (G * ya) - ((G - 1) * xa) + jz, az, bz = jzazbz - # Convert to LMS - lms = alg.dot(xyz_to_lms_m, [xm, ym, za], dims=alg.D2_D1) + # Calculate Iz + iz = alg.zdiv((jz + D0), (1 + D - D * (jz + D0))) - # PQ encode the LMS - pqlms = util.pq_st2084_oetf(lms, m2=M2) + # Convert back to normal XYZ D65 + return util.absxyz_to_xyz(izazbz_to_xyz_d65([iz, az, bz], IZAZBZ_TO_LMS_P, M2), YW) - # Calculate Izazbz - iz, az, bz = alg.dot(lms_p_to_izazbz_m, pqlms, dims=alg.D2_D1) + +def xyz_d65_to_jzazbz(xyzd65: Vector) -> Vector: + """From XYZ to Jzazbz.""" + + iz, az, bz = xyz_d65_to_izazbz(util.xyz_to_absxyz(xyzd65, YW), LMS_P_TO_IZAZBZ, M2) # Calculate Jz jz = ((1 + D) * iz) / (1 + (D * iz)) - D0 return [jz, az, bz] -class Jzazbz(Labish, Space): +class Jzazbz(Lab, Space): """Jzazbz class.""" BASE = "xyz-d65" NAME = "jzazbz" - SERIALIZE = ("--jzazbz",) + SERIALIZE = ("jzazbz", "--jzazbz",) CHANNELS = ( - Channel("jz", 0.0, 1.0, limit=(0.0, None)), - Channel("az", -0.5, 0.5, flags=FLG_MIRROR_PERCENT), - Channel("bz", -0.5, 0.5, flags=FLG_MIRROR_PERCENT) + Channel("jz", 0.0, 1.0), + Channel("az", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), + Channel("bz", -1.0, 1.0, flags=FLG_MIRROR_PERCENT) ) CHANNEL_ALIASES = { "lightness": 'jz', "a": 'az', - "b": 'bz' + "b": 'bz', + "j": 'jz' } WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' - # Precalculated from - # [ - # (1, 5, 5, 1000.0), - # (1, 52, 2, 200.0), - # (30, 521, 8, 100.0) - # ] - ACHROMATIC = Achromatic( - ACHROMATIC_RESPONSE, - 2.121e-7, - 9.863e-7, - 0.00039, - 'catrom', - ) - - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index in (1, 2): - if not math.isnan(coords[index]): - return coords[index] - - return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1] - - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value - - def is_achromatic(self, coords: Vector) -> bool: - """Check if color is achromatic.""" - - c, h = alg.rect_to_polar(coords[1], coords[2]) - return coords[0] == 0.0 or self.ACHROMATIC.test(coords[0], c, h) def to_base(self, coords: Vector) -> Vector: """To XYZ from Jzazbz.""" diff --git a/lib/coloraide/spaces/jzczhz.py b/lib/coloraide/spaces/jzczhz.py index 06a49d2..7ca71ea 100644 --- a/lib/coloraide/spaces/jzczhz.py +++ b/lib/coloraide/spaces/jzczhz.py @@ -3,12 +3,10 @@ https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272 """ -import math +from __future__ import annotations from ..cat import WHITES from .lch import LCh -from .jzazbz import Jzazbz from ..channels import Channel, FLG_ANGLE -from ..types import Vector class JzCzhz(LCh): @@ -20,41 +18,29 @@ class JzCzhz(LCh): BASE = "jzazbz" NAME = "jzczhz" - SERIALIZE = ("--jzczhz",) + SERIALIZE = ("jzczhz", "--jzczhz",) WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' CHANNEL_ALIASES = { "lightness": "jz", "chroma": "cz", - "hue": "hz" + "hue": "hz", + "h": 'hz', + 'c': 'cz', + 'j': 'jz' } - ACHROMATIC = Jzazbz.ACHROMATIC CHANNELS = ( - Channel("jz", 0.0, 1.0, limit=(0.0, None)), - Channel("cz", 0.0, 0.5, limit=(0.0, None)), - Channel("hz", 0.0, 360.0, flags=FLG_ANGLE, nans=ACHROMATIC.hue) + Channel("jz", 0.0, 1.0), + Channel("cz", 0.0, 1.0), + Channel("hz", 0.0, 360.0, flags=FLG_ANGLE) ) - def resolve_channel(self, index: int, coords: Vector) -> float: - """Resolve channels.""" - - if index == 2: - h = coords[2] - return self.ACHROMATIC.get_ideal_hue(coords[0]) if math.isnan(h) else h - - elif index == 1: - c = coords[1] - return self.ACHROMATIC.get_ideal_chroma(coords[0]) if math.isnan(c) else c - - value = coords[index] - return self.channels[index].nans if math.isnan(value) else value - - def is_achromatic(self, coords: Vector) -> bool: - """Check if color is achromatic.""" - - return coords[0] == 0.0 or self.ACHROMATIC.test(*coords) - def hue_name(self) -> str: """Hue name.""" return "hz" + + def radial_name(self) -> str: + """Radial name.""" + + return "cz" diff --git a/lib/coloraide/spaces/lab/__init__.py b/lib/coloraide/spaces/lab/__init__.py index ce4e7d3..fa9e753 100644 --- a/lib/coloraide/spaces/lab/__init__.py +++ b/lib/coloraide/spaces/lab/__init__.py @@ -1,7 +1,13 @@ -"""Lab class.""" +""" +Lab class. + +https://ia802802.us.archive.org/23/items/gov.law.cie.15.2004/cie.15.2004.pdf +http://www.brucelindbloom.com/Eqn_Lab_to_XYZ.html +""" +from __future__ import annotations from ...spaces import Space, Labish from ...cat import WHITES -from ...channels import Channel, FLG_OPT_PERCENT, FLG_MIRROR_PERCENT +from ...channels import Channel, FLG_MIRROR_PERCENT from ... import util from ... import algebra as alg from ...types import VectorLike, Vector @@ -14,14 +20,7 @@ def lab_to_xyz(lab: Vector, white: VectorLike) -> Vector: - """ - Convert Lab to D50-adapted XYZ. - - http://www.brucelindbloom.com/Eqn_Lab_to_XYZ.html - - While the derivation is different than the specification, the results are the same as Appendix D: - https://www.cdvplus.cz/file/3-publikace-cie15-2004/ - """ + """Convert CIE Lab to XYZ using the reference white.""" l, a, b = lab @@ -42,14 +41,7 @@ def lab_to_xyz(lab: Vector, white: VectorLike) -> Vector: def xyz_to_lab(xyz: Vector, white: VectorLike) -> Vector: - """ - Assuming XYZ is relative to D50, convert to CIELab from CIE standard. - - http://www.brucelindbloom.com/Eqn_XYZ_to_Lab.html - - While the derivation is different than the specification, the results are the same: - https://www.cdvplus.cz/file/3-publikace-cie15-2004/ - """ + """Convert XYZ to CIE Lab using the reference white.""" # compute `xyz`, which is XYZ scaled relative to reference white xyz = alg.divide(xyz, white, dims=alg.D1) @@ -66,18 +58,14 @@ def xyz_to_lab(xyz: Vector, white: VectorLike) -> Vector: class Lab(Labish, Space): """Lab class.""" - BASE = "xyz-d50" - NAME = "lab" - SERIALIZE = ("--lab",) CHANNELS = ( - Channel("l", 0.0, 100.0, flags=FLG_OPT_PERCENT), - Channel("a", -125.0, 125.0, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT), - Channel("b", -125.0, 125.0, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT) + Channel("l", 0.0, 1.0), + Channel("a", 1.0, 1.0, flags=FLG_MIRROR_PERCENT), + Channel("b", 1.0, 1.0, flags=FLG_MIRROR_PERCENT) ) CHANNEL_ALIASES = { "lightness": "l" } - WHITE = WHITES['2deg']['D50'] def is_achromatic(self, coords: Vector) -> bool: """Check if color is achromatic.""" @@ -93,3 +81,17 @@ def from_base(self, coords: Vector) -> Vector: """From XYZ D50 to Lab.""" return xyz_to_lab(coords, util.xy_to_xyz(self.white())) + + +class CIELab(Lab): + """CIE Lab D50.""" + + BASE = "xyz-d50" + NAME = "lab" + SERIALIZE = ("--lab",) + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("a", -125.0, 125.0, flags=FLG_MIRROR_PERCENT), + Channel("b", -125.0, 125.0, flags=FLG_MIRROR_PERCENT) + ) + WHITE = WHITES['2deg']['D50'] diff --git a/lib/coloraide/spaces/lab/css.py b/lib/coloraide/spaces/lab/css.py index 9fea020..c36475a 100644 --- a/lib/coloraide/spaces/lab/css.py +++ b/lib/coloraide/spaces/lab/css.py @@ -1,27 +1,28 @@ """Lab class.""" +from __future__ import annotations from .. import lab as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color -class Lab(base.Lab): +class Lab(base.CIELab): """Lab class.""" def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS.""" @@ -42,7 +43,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/lab_d65.py b/lib/coloraide/spaces/lab_d65.py index a66b2da..3b04cd3 100644 --- a/lib/coloraide/spaces/lab_d65.py +++ b/lib/coloraide/spaces/lab_d65.py @@ -1,10 +1,11 @@ """Lab D65 class.""" +from __future__ import annotations from ..cat import WHITES -from .lab import Lab +from .lab import CIELab from ..channels import Channel, FLG_MIRROR_PERCENT -class LabD65(Lab): +class LabD65(CIELab): """Lab D65 class.""" BASE = 'xyz-d65' diff --git a/lib/coloraide/spaces/lch/__init__.py b/lib/coloraide/spaces/lch/__init__.py index c52733d..68c46f1 100644 --- a/lib/coloraide/spaces/lch/__init__.py +++ b/lib/coloraide/spaces/lch/__init__.py @@ -1,7 +1,8 @@ """LCh class.""" +from __future__ import annotations from ...spaces import Space, LChish from ...cat import WHITES -from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT +from ...channels import Channel, FLG_ANGLE from ... import util import math from ...types import Vector @@ -35,12 +36,9 @@ def lch_to_lab(lch: Vector) -> Vector: class LCh(LChish, Space): """LCh class.""" - BASE = "lab" - NAME = "lch" - SERIALIZE = ("--lch",) CHANNELS = ( - Channel("l", 0.0, 100.0, flags=FLG_OPT_PERCENT), - Channel("c", 0.0, 150.0, limit=(0.0, None), flags=FLG_OPT_PERCENT), + Channel("l", 0.0, 1.0), + Channel("c", 0.0, 1.0), Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) CHANNEL_ALIASES = { @@ -48,12 +46,21 @@ class LCh(LChish, Space): "chroma": "c", "hue": "h" } - WHITE = WHITES['2deg']['D50'] - def is_achromatic(self, coords: Vector) -> bool: + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + coords[1] *= -1.0 + coords[2] += 180.0 + coords[2] %= 360.0 + return coords + + def is_achromatic(self, coords: Vector) -> bool | None: """Check if color is achromatic.""" - return coords[1] < ACHROMATIC_THRESHOLD + # Account for both positive and negative chroma + return abs(coords[1]) < ACHROMATIC_THRESHOLD def to_base(self, coords: Vector) -> Vector: """To Lab from LCh.""" @@ -64,3 +71,22 @@ def from_base(self, coords: Vector) -> Vector: """From Lab to LCh.""" return lab_to_lch(coords) + + +class CIELCh(LCh): + """CIE LCh D50.""" + + BASE = "lab" + NAME = "lch" + SERIALIZE = ("--lch",) + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("c", 0.0, 150.0), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) + CHANNEL_ALIASES = { + "lightness": "l", + "chroma": "c", + "hue": "h" + } + WHITE = WHITES['2deg']['D50'] diff --git a/lib/coloraide/spaces/lch/css.py b/lib/coloraide/spaces/lch/css.py index bf274f4..89b2b1e 100644 --- a/lib/coloraide/spaces/lch/css.py +++ b/lib/coloraide/spaces/lch/css.py @@ -1,27 +1,28 @@ """LCh class.""" +from __future__ import annotations from .. import lch as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color -class LCh(base.LCh): +class LCh(base.CIELCh): """LCh class.""" def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS.""" @@ -42,7 +43,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/lch99o.py b/lib/coloraide/spaces/lch99o.py index 170457e..d5e0ef2 100644 --- a/lib/coloraide/spaces/lch99o.py +++ b/lib/coloraide/spaces/lch99o.py @@ -1,4 +1,5 @@ """DIN99o LCh class.""" +from __future__ import annotations from ..cat import WHITES from .lch import LCh from ..channels import Channel, FLG_ANGLE @@ -13,6 +14,6 @@ class LCh99o(LCh): WHITE = WHITES['2deg']['D65'] CHANNELS = ( Channel("l", 0.0, 100.0), - Channel("c", 0.0, 60.0, limit=(0.0, None)), + Channel("c", 0.0, 60.0), Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) diff --git a/lib/coloraide/spaces/lch_d65.py b/lib/coloraide/spaces/lch_d65.py index 705e847..dbc46bb 100644 --- a/lib/coloraide/spaces/lch_d65.py +++ b/lib/coloraide/spaces/lch_d65.py @@ -1,10 +1,11 @@ """LCh D65 class.""" +from __future__ import annotations from ..cat import WHITES -from .lch import LCh +from .lch import CIELCh from ..channels import Channel, FLG_ANGLE -class LChD65(LCh): +class LChD65(CIELCh): """LCh D65 class.""" BASE = "lab-d65" @@ -13,6 +14,6 @@ class LChD65(LCh): WHITE = WHITES['2deg']['D65'] CHANNELS = ( Channel("l", 0.0, 100.0), - Channel("c", 0.0, 160.0, limit=(0.0, None)), + Channel("c", 0.0, 160.0), Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) diff --git a/lib/coloraide/spaces/lchuv.py b/lib/coloraide/spaces/lchuv.py index c035262..2cf4737 100644 --- a/lib/coloraide/spaces/lchuv.py +++ b/lib/coloraide/spaces/lchuv.py @@ -1,4 +1,5 @@ """LChuv class.""" +from __future__ import annotations from ..spaces import Space from ..cat import WHITES from ..channels import Channel, FLG_ANGLE @@ -15,7 +16,7 @@ class LChuv(LCh, Space): WHITE = WHITES['2deg']['D65'] CHANNELS = ( Channel("l", 0.0, 100.0), - Channel("c", 0.0, 220.0, limit=(0.0, None)), + Channel("c", 0.0, 220.0), Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) diff --git a/lib/coloraide/spaces/luv.py b/lib/coloraide/spaces/luv.py index 1811739..b96404b 100644 --- a/lib/coloraide/spaces/luv.py +++ b/lib/coloraide/spaces/luv.py @@ -3,6 +3,7 @@ https://en.wikipedia.org/wiki/CIELuv """ +from __future__ import annotations from ..spaces import Space, Labish from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT @@ -10,10 +11,9 @@ from .. import util from .. import algebra as alg from ..types import Vector -from typing import Tuple -def xyz_to_luv(xyz: Vector, white: Tuple[float, float]) -> Vector: +def xyz_to_luv(xyz: Vector, white: tuple[float, float]) -> Vector: """XYZ to Luv.""" u, v = util.xy_to_uv(util.xyz_to_xyY(xyz, white)[:2]) @@ -30,7 +30,7 @@ def xyz_to_luv(xyz: Vector, white: Tuple[float, float]) -> Vector: ] -def luv_to_xyz(luv: Vector, white: Tuple[float, float]) -> Vector: +def luv_to_xyz(luv: Vector, white: tuple[float, float]) -> Vector: """Luv to XYZ.""" l, u, v = luv diff --git a/lib/coloraide/spaces/okhsl.py b/lib/coloraide/spaces/okhsl.py index 3897031..b2de597 100644 --- a/lib/coloraide/spaces/okhsl.py +++ b/lib/coloraide/spaces/okhsl.py @@ -25,6 +25,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from .hsl import HSL from ..channels import Channel, FLG_ANGLE from .. import util @@ -33,7 +34,6 @@ from .. import algebra as alg from ..types import Vector, Matrix from . oklab import OKLAB_TO_LMS3 -from typing import Optional, List SRGBL_TO_LMS = [ [0.4122214694707629, 0.5363325372617349, 0.051445993267502196], @@ -67,9 +67,9 @@ # Limit [0.13110758, 1.81333971], # `Kn` coefficients - [1.35691251, -0.00926975, -1.15076744, -0.50647251, 0.00645585] + [1.35733652, -0.00915799, -1.1513021, -0.50559606, 0.00692167] ] -] # type: List[List[Vector]] +] # type: list[Matrix] FLT_MAX = sys.float_info.max @@ -142,9 +142,9 @@ def oklab_to_linear_rgb(lab: Vector, lms_to_rgb: Matrix) -> Vector: that transform the LMS values to the linear RGB space. """ - return alg.dot( + return alg.matmul( lms_to_rgb, - [c ** 3 for c in alg.dot(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], + [c ** 3 for c in alg.matmul(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -153,7 +153,7 @@ def find_cusp( a: float, b: float, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """ Finds L_cusp and C_cusp for a given hue. @@ -179,8 +179,8 @@ def find_gamut_intersection( c1: float, l0: float, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]], - cusp: Optional[Vector] = None + ok_coeff: list[Matrix], + cusp: Vector | None = None, ) -> float: """ Finds intersection of the line. @@ -273,7 +273,7 @@ def find_gamut_intersection( def get_cs( lab: Vector, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """Get Cs.""" @@ -309,7 +309,7 @@ def compute_max_saturation( a: float, b: float, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> float: """ Finds the maximum saturation possible for a given hue that fits in RGB. @@ -376,7 +376,7 @@ def compute_max_saturation( def okhsl_to_oklab( hsl: Vector, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """Convert Okhsl to Oklab.""" @@ -387,8 +387,8 @@ def okhsl_to_oklab( a = b = 0.0 if L != 0.0 and L != 1.0 and s != 0: - a_ = math.cos(alg.tau * h) - b_ = math.sin(alg.tau * h) + a_ = math.cos(math.tau * h) + b_ = math.sin(math.tau * h) c_0, c_mid, c_max = get_cs([L, a_, b_], lms_to_rgb, ok_coeff) @@ -425,7 +425,7 @@ def okhsl_to_oklab( def oklab_to_okhsl( lab: Vector, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """Oklab to Okhsl.""" @@ -434,7 +434,7 @@ def oklab_to_okhsl( l = toe(L) c = math.sqrt(lab[1] ** 2 + lab[2] ** 2) - h = 0.5 + math.atan2(-lab[2], -lab[1]) / alg.tau + h = 0.5 + math.atan2(-lab[2], -lab[1]) / math.tau if l != 0.0 and l != 1.0 and c != 0: a_ = lab[1] / c @@ -470,7 +470,7 @@ class Okhsl(HSL): NAME = "okhsl" SERIALIZE = ("--okhsl",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, 1.0, bound=True), Channel("l", 0.0, 1.0, bound=True) ) @@ -479,6 +479,16 @@ class Okhsl(HSL): "saturation": "s", "lightness": "l" } + GAMUT_CHECK = None + CLIP_SPACE = None + + def normalize(self, coords: Vector) -> Vector: + """Normalize coordinates.""" + + if coords[1] < 0: + return self.from_base(self.to_base(coords)) + coords[0] %= 360.0 + return coords def to_base(self, coords: Vector) -> Vector: """To Oklab from Okhsl.""" diff --git a/lib/coloraide/spaces/okhsv.py b/lib/coloraide/spaces/okhsv.py index bd59f8b..f51a3c9 100644 --- a/lib/coloraide/spaces/okhsv.py +++ b/lib/coloraide/spaces/okhsv.py @@ -25,6 +25,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from .hsv import HSV from ..channels import FLG_ANGLE, Channel from .. import util @@ -32,13 +33,12 @@ import math from .. import algebra as alg from ..types import Vector, Matrix -from typing import List def okhsv_to_oklab( hsv: Vector, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """Convert from Okhsv to Oklab.""" @@ -51,8 +51,8 @@ def okhsv_to_oklab( # Avoid processing gray or colors with undefined hues if l != 0.0 and s != 0.0: - a_ = math.cos(alg.tau * h) - b_ = math.sin(alg.tau * h) + a_ = math.cos(math.tau * h) + b_ = math.sin(math.tau * h) cusp = find_cusp(a_, b_, lms_to_rgb, ok_coeff) s_max, t_max = to_st(cusp) @@ -92,7 +92,7 @@ def okhsv_to_oklab( def oklab_to_okhsv( lab: Vector, lms_to_rgb: Matrix, - ok_coeff: List[List[Vector]] + ok_coeff: list[Matrix] ) -> Vector: """Oklab to Okhsv.""" @@ -101,7 +101,7 @@ def oklab_to_okhsv( v = toe(l) c = math.sqrt(lab[1] ** 2 + lab[2] ** 2) - h = 0.5 + math.atan2(-lab[2], -lab[1]) / alg.tau + h = 0.5 + math.atan2(-lab[2], -lab[1]) / math.tau if l != 0.0 and l != 1 and c != 0.0: a_ = lab[1] / c @@ -144,7 +144,7 @@ class Okhsv(HSV): NAME = "okhsv" SERIALIZE = ("--okhsv",) CHANNELS = ( - Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE), Channel("s", 0.0, 1.0, bound=True), Channel("v", 0.0, 1.0, bound=True) ) @@ -153,6 +153,8 @@ class Okhsv(HSV): "saturation": "s", "value": "v" } + GAMUT_CHECK = None + CLIP_SPACE = None def to_base(self, okhsv: Vector) -> Vector: """To Oklab from Okhsv.""" diff --git a/lib/coloraide/spaces/oklab/__init__.py b/lib/coloraide/spaces/oklab/__init__.py index 315166f..296a43b 100644 --- a/lib/coloraide/spaces/oklab/__init__.py +++ b/lib/coloraide/spaces/oklab/__init__.py @@ -25,8 +25,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from ...cat import WHITES -from ...channels import Channel, FLG_OPT_PERCENT, FLG_MIRROR_PERCENT +from ...channels import Channel, FLG_MIRROR_PERCENT from ... import algebra as alg from ...types import Vector from ..lab import Lab @@ -63,9 +64,9 @@ def oklab_to_xyz_d65(lab: Vector) -> Vector: """Convert from Oklab to XYZ D65.""" - return alg.dot( + return alg.matmul( LMS_TO_XYZD65, - [c ** 3 for c in alg.dot(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], + [c ** 3 for c in alg.matmul(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -73,9 +74,9 @@ def oklab_to_xyz_d65(lab: Vector) -> Vector: def xyz_d65_to_oklab(xyz: Vector) -> Vector: """XYZ D65 to Oklab.""" - return alg.dot( + return alg.matmul( LMS3_TO_OKLAB, - [alg.nth_root(c, 3) for c in alg.dot(XYZD65_TO_LMS, xyz, dims=alg.D2_D1)], + [alg.nth_root(c, 3) for c in alg.matmul(XYZD65_TO_LMS, xyz, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -87,9 +88,9 @@ class Oklab(Lab): NAME = "oklab" SERIALIZE = ("--oklab",) CHANNELS = ( - Channel("l", 0.0, 1.0, flags=FLG_OPT_PERCENT), - Channel("a", -0.4, 0.4, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT), - Channel("b", -0.4, 0.4, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT) + Channel("l", 0.0, 1.0), + Channel("a", -0.4, 0.4, flags=FLG_MIRROR_PERCENT), + Channel("b", -0.4, 0.4, flags=FLG_MIRROR_PERCENT) ) CHANNEL_ALIASES = { "lightness": "l" diff --git a/lib/coloraide/spaces/oklab/css.py b/lib/coloraide/spaces/oklab/css.py index 613927c..321621c 100644 --- a/lib/coloraide/spaces/oklab/css.py +++ b/lib/coloraide/spaces/oklab/css.py @@ -1,9 +1,10 @@ """Oklab class.""" +from __future__ import annotations from .. import oklab as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color @@ -14,14 +15,14 @@ class Oklab(base.Oklab): def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS.""" @@ -42,7 +43,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/oklch/__init__.py b/lib/coloraide/spaces/oklch/__init__.py index 0c0f432..a0d9a0c 100644 --- a/lib/coloraide/spaces/oklch/__init__.py +++ b/lib/coloraide/spaces/oklch/__init__.py @@ -23,9 +23,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations from ..lch import LCh from ...cat import WHITES -from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT +from ...channels import Channel, FLG_ANGLE class OkLCh(LCh): @@ -35,8 +36,8 @@ class OkLCh(LCh): NAME = "oklch" SERIALIZE = ("--oklch",) CHANNELS = ( - Channel("l", 0.0, 1.0, flags=FLG_OPT_PERCENT), - Channel("c", 0.0, 0.4, limit=(0.0, None), flags=FLG_OPT_PERCENT), + Channel("l", 0.0, 1.0), + Channel("c", 0.0, 0.4), Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) CHANNEL_ALIASES = { diff --git a/lib/coloraide/spaces/oklch/css.py b/lib/coloraide/spaces/oklch/css.py index 2e66a92..e81057c 100644 --- a/lib/coloraide/spaces/oklch/css.py +++ b/lib/coloraide/spaces/oklch/css.py @@ -1,9 +1,10 @@ """OkLCh class.""" +from __future__ import annotations from .. import oklch as base from ...css import parse from ...css import serialize from ...types import Vector -from typing import Union, Optional, Tuple, Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Sequence if TYPE_CHECKING: # pragma: no cover from ...color import Color @@ -14,14 +15,14 @@ class OkLCh(base.OkLCh): def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[str, bool] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, **kwargs: Any ) -> str: """Convert to CSS.""" @@ -42,7 +43,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> tuple[tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/orgb.py b/lib/coloraide/spaces/orgb.py index 986626b..313f1a4 100644 --- a/lib/coloraide/spaces/orgb.py +++ b/lib/coloraide/spaces/orgb.py @@ -3,13 +3,13 @@ https://graphics.stanford.edu/~boulos/papers/orgb_sig.pdf """ +from __future__ import annotations import math from .. import algebra as alg from ..spaces import Space, Labish from ..types import Vector from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT -from typing import Tuple RGB_TO_LC1C2 = [ [0.2990, 0.5870, 0.1140], @@ -26,13 +26,13 @@ def rotate(v: Vector, d: float) -> Vector: m = alg.identity(3) m[1][1:] = math.cos(d), -math.sin(d) m[2][1:] = math.sin(d), math.cos(d) - return alg.dot(m, v, dims=alg.D2_D1) + return alg.matmul(m, v, dims=alg.D2_D1) def srgb_to_orgb(rgb: Vector) -> Vector: """Convert sRGB to oRGB.""" - lcc = alg.dot(RGB_TO_LC1C2, rgb, dims=alg.D2_D1) + lcc = alg.matmul(RGB_TO_LC1C2, rgb, dims=alg.D2_D1) theta = math.atan2(lcc[2], lcc[1]) theta0 = theta atheta = abs(theta) @@ -55,7 +55,7 @@ def orgb_to_srgb(lcc: Vector) -> Vector: elif (math.pi / 2) <= atheta0 <= math.pi: theta = math.copysign((math.pi / 3) + (4 / 3) * (atheta0 - math.pi / 2), theta0) - return alg.dot(LC1C2_TO_RGB, rotate(lcc, theta - theta0), dims=alg.D2_D1) + return alg.matmul(LC1C2_TO_RGB, rotate(lcc, theta - theta0), dims=alg.D2_D1) class oRGB(Labish, Space): @@ -74,6 +74,7 @@ class oRGB(Labish, Space): CHANNEL_ALIASES = { "luma": "l" } + GAMUT_CHECK = 'srgb' def to_base(self, coords: Vector) -> Vector: """To base from oRGB.""" diff --git a/lib/coloraide/spaces/prismatic.py b/lib/coloraide/spaces/prismatic.py index 364b829..4e85eda 100644 --- a/lib/coloraide/spaces/prismatic.py +++ b/lib/coloraide/spaces/prismatic.py @@ -6,13 +6,13 @@ http://psgraphics.blogspot.com/2015/10/prismatic-color-model.html https://studylib.net/doc/14656976/the-prismatic-color-space-for-rgb-computations """ +from __future__ import annotations from ..spaces import Space from ..channels import Channel from ..cat import WHITES from ..types import Vector from .. import algebra as alg import math -from typing import Tuple def srgb_to_lrgb(rgb: Vector) -> Vector: @@ -37,7 +37,7 @@ class Prismatic(Space): BASE = "srgb" NAME = "prismatic" - SERIALIZE = ("--prismatic",) # type: Tuple[str, ...] + SERIALIZE = ("--prismatic",) # type: tuple[str, ...] EXTENDED_RANGE = False CHANNELS = ( Channel("l", 0.0, 1.0, bound=True), @@ -52,16 +52,18 @@ class Prismatic(Space): "blue": 'b' } WHITE = WHITES['2deg']['D65'] + GAMUT_CHECK = 'srgb' + CLIP_SPACE = 'prismatic' def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" - if alg.isclose(0.0, coords[0], abs_tol=1e-4, dims=alg.SC): + if math.isclose(0.0, coords[0], abs_tol=1e-4): return True white = [1, 1, 1] for x in alg.vcross(coords[:-1], white): - if not alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): + if not math.isclose(0.0, x, abs_tol=1e-5): return False return True diff --git a/lib/coloraide/spaces/prophoto_rgb.py b/lib/coloraide/spaces/prophoto_rgb.py index 01e7af0..1332409 100644 --- a/lib/coloraide/spaces/prophoto_rgb.py +++ b/lib/coloraide/spaces/prophoto_rgb.py @@ -1,6 +1,7 @@ """Pro Photo RGB color class.""" +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -23,7 +24,7 @@ def lin_prophoto(rgb: Vector) -> Vector: if abs(i) < ET2: result.append(i / 16.0) else: - result.append(alg.npow(i, 1.8)) + result.append(alg.spow(i, 1.8)) return result @@ -46,13 +47,18 @@ def gam_prophoto(rgb: Vector) -> Vector: return result -class ProPhotoRGB(sRGB): +class ProPhotoRGB(sRGBLinear): """Pro Photo RGB class.""" BASE = "prophoto-rgb-linear" NAME = "prophoto-rgb" WHITE = WHITES['2deg']['D50'] + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE + def to_base(self, coords: Vector) -> Vector: """To XYZ from Pro Photo RGB.""" diff --git a/lib/coloraide/spaces/prophoto_rgb_linear.py b/lib/coloraide/spaces/prophoto_rgb_linear.py index 0087998..520777e 100644 --- a/lib/coloraide/spaces/prophoto_rgb_linear.py +++ b/lib/coloraide/spaces/prophoto_rgb_linear.py @@ -1,6 +1,7 @@ """Linear Pro Photo RGB color class.""" +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -25,16 +26,16 @@ def lin_prophoto_to_xyz(rgb: Vector) -> Vector: http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html """ - return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_prophoto(xyz: Vector) -> Vector: """Convert XYZ to linear-light prophoto-rgb.""" - return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) -class ProPhotoRGBLinear(sRGB): +class ProPhotoRGBLinear(sRGBLinear): """Linear Pro Photo RGB class.""" BASE = "xyz-d50" diff --git a/lib/coloraide/spaces/rec2020.py b/lib/coloraide/spaces/rec2020.py index 6e025a6..4e5cfb2 100644 --- a/lib/coloraide/spaces/rec2020.py +++ b/lib/coloraide/spaces/rec2020.py @@ -1,6 +1,6 @@ """Rec 2020 color class.""" -from ..cat import WHITES -from .srgb import sRGB +from __future__ import annotations +from .srgb_linear import sRGBLinear import math from .. import algebra as alg from ..types import Vector @@ -47,12 +47,16 @@ def gam_2020(rgb: Vector) -> Vector: return result -class Rec2020(sRGB): +class Rec2020(sRGBLinear): """Rec 2020 class.""" BASE = "rec2020-linear" NAME = "rec2020" - WHITE = WHITES['2deg']['D65'] + + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE def to_base(self, coords: Vector) -> Vector: """To XYZ from Rec. 2020.""" diff --git a/lib/coloraide/spaces/rec2020_linear.py b/lib/coloraide/spaces/rec2020_linear.py index 8bc2cca..950346e 100644 --- a/lib/coloraide/spaces/rec2020_linear.py +++ b/lib/coloraide/spaces/rec2020_linear.py @@ -1,6 +1,7 @@ """Linear Rec 2020 color class.""" +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector @@ -29,16 +30,16 @@ def lin_2020_to_xyz(rgb: Vector) -> Vector: http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html """ - return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_2020(xyz: Vector) -> Vector: """Convert XYZ to linear-light rec-2020.""" - return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) -class Rec2020Linear(sRGB): +class Rec2020Linear(sRGBLinear): """Linear Rec 2020 class.""" BASE = "xyz-d65" diff --git a/lib/coloraide/spaces/rec2100_hlg.py b/lib/coloraide/spaces/rec2100_hlg.py index f14ef52..854b1e7 100644 --- a/lib/coloraide/spaces/rec2100_hlg.py +++ b/lib/coloraide/spaces/rec2100_hlg.py @@ -14,8 +14,9 @@ Suggests the scale of 0.26496256042100724 to satisfy the above requirement. """ +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB +from .srgb_linear import sRGBLinear from .. import algebra as alg from ..types import Vector import math @@ -29,6 +30,7 @@ class Environment: def __init__( self, + *, lw: float, lb: float, scale: float @@ -83,15 +85,24 @@ def hlg_eotf(values: Vector, env: Environment) -> Vector: return adjusted -class Rec2100HLG(sRGB): +class Rec2100HLG(sRGBLinear): """Rec. 2100 HLG class.""" - BASE = "rec2020-linear" + BASE = "rec2100-linear" NAME = "rec2100-hlg" - SERIALIZE = ('--rec2100-hlg',) + SERIALIZE = ('rec2100-hlg', '--rec2100-hlg',) WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' - ENV = Environment(1000, 0, SCALE) + ENV = Environment( + lw=1000, + lb=0, + scale=SCALE + ) + + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE def to_base(self, coords: Vector) -> Vector: """To base from Rec 2100 HLG.""" diff --git a/lib/coloraide/spaces/rec2100_linear.py b/lib/coloraide/spaces/rec2100_linear.py new file mode 100644 index 0000000..0813dd7 --- /dev/null +++ b/lib/coloraide/spaces/rec2100_linear.py @@ -0,0 +1,16 @@ +""" +Linear Rec. 2100. + +As defined in CSS, this is simply an alias for linear Rec. 2020. +""" +from __future__ import annotations +from ..cat import WHITES +from .rec2020_linear import Rec2020Linear + + +class Rec2100Linear(Rec2020Linear): + """Linear Rec. 2100 class.""" + + NAME = "rec2100-linear" + SERIALIZE = ('rec2100-linear',) + WHITE = WHITES['2deg']['D65'] diff --git a/lib/coloraide/spaces/rec2100_pq.py b/lib/coloraide/spaces/rec2100_pq.py index d69b3e8..edef8d4 100644 --- a/lib/coloraide/spaces/rec2100_pq.py +++ b/lib/coloraide/spaces/rec2100_pq.py @@ -3,28 +3,35 @@ https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.2100-2-201807-I!!PDF-E.pdf """ +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB -from .. import algebra as alg +from .srgb_linear import sRGBLinear from ..types import Vector from .. import util +YW = 203 -class Rec2100PQ(sRGB): + +class Rec2100PQ(sRGBLinear): """Rec. 2100 PQ class.""" - BASE = "rec2020-linear" + BASE = "rec2100-linear" NAME = "rec2100-pq" - SERIALIZE = ('--rec2100-pq',) + SERIALIZE = ('rec2100-pq', '--rec2100-pq',) WHITE = WHITES['2deg']['D65'] DYNAMIC_RANGE = 'hdr' + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE + def to_base(self, coords: Vector) -> Vector: - """To XYZ from Rec. 2100 PQ.""" + """To base from Rec. 2100 PQ.""" - return alg.divide(util.pq_st2084_eotf(coords), util.YW, dims=alg.D1_SC) + return [c / YW for c in util.pq_st2084_eotf(coords)] def from_base(self, coords: Vector) -> Vector: - """From XYZ to Rec. 2100 PQ.""" + """From base to Rec. 2100 PQ.""" - return util.pq_st2084_oetf(alg.multiply(coords, util.YW, dims=alg.D1_SC)) + return util.pq_st2084_oetf([c * YW for c in coords]) diff --git a/lib/coloraide/spaces/rec709.py b/lib/coloraide/spaces/rec709.py index 4a309ce..1eef141 100644 --- a/lib/coloraide/spaces/rec709.py +++ b/lib/coloraide/spaces/rec709.py @@ -7,7 +7,8 @@ - https://en.wikipedia.org/wiki/Rec._709 - https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf """ -from .srgb import sRGB +from __future__ import annotations +from .srgb_linear import sRGBLinear import math from .. import algebra as alg from ..types import Vector @@ -54,13 +55,18 @@ def gam_709(rgb: Vector) -> Vector: return result -class Rec709(sRGB): +class Rec709(sRGBLinear): """Rec. 709 class.""" BASE = "srgb-linear" NAME = "rec709" SERIALIZE = ("--rec709",) + def linear(self) -> str: + """Return linear version of the RGB (if available).""" + + return self.BASE + def to_base(self, coords: Vector) -> Vector: """To XYZ from Rec. 709.""" diff --git a/lib/coloraide/spaces/rlab.py b/lib/coloraide/spaces/rlab.py index 46b8b32..aa57d51 100644 --- a/lib/coloraide/spaces/rlab.py +++ b/lib/coloraide/spaces/rlab.py @@ -4,6 +4,8 @@ https://scholarworks.rit.edu/cgi/viewcontent.cgi?article=1153&context=article Compared against http://markfairchild.org/files/AppModEx.xls """ +from __future__ import annotations +import math from ..cat import WHITES from .. import util from ..spaces.lab import Lab @@ -25,7 +27,7 @@ ] # Defaults -YN = 318.0 # `318 cd / m^2` +YN = 1000 / math.pi # `1000 lux == ~318.31 cd / m^2` # Sigma is usually defined as 1 / x, but we are using x due to the way we use them SURROUND = { @@ -49,20 +51,27 @@ class Environment: """RLAB environment.""" - def __init__(self, white: VectorLike, adapting_luminance: float, surround: float, discounting: float) -> None: + def __init__( + self, + *, + white: VectorLike, + adapting_luminance: float, + surround: str, + discounting: str + ) -> None: """Initialize.""" - self.xyz_w = util.xy_to_xyz(white) - self.surround = surround + self.ref_white = util.xy_to_xyz(white) + self.surround = SURROUND[surround] self.yn = adapting_luminance - self.d = discounting + self.d = alg.clamp(D[discounting] if isinstance(discounting, str) else discounting, 0.0, 1.0) self.ram = self.calc_ram() self.iram = alg.inv(self.ram) def calc_ram(self) -> Matrix: """Calculate RAM.""" - lms = alg.dot(M, self.xyz_w) + lms = alg.matmul(M, self.ref_white, dims=alg.D2_D1) a = [] # type: Vector s = sum(lms) for c in lms: @@ -77,16 +86,16 @@ def rlab_to_xyz(rlab: Vector, env: Environment) -> Vector: """RLAB to XYZ.""" LR, aR, bR = rlab - yr = LR / 100 - xr = alg.npow((aR / 430) + yr, env.surround) - zr = alg.npow(yr - (bR / 170), env.surround) - return alg.dot(env.iram, [xr, alg.npow(yr, env.surround), zr], dims=alg.D2_D1) + yr = LR * 0.01 + xr = alg.spow((aR / 430) + yr, env.surround) + zr = alg.spow(yr - (bR / 170), env.surround) + return alg.matmul(env.iram, [xr, alg.spow(yr, env.surround), zr], dims=alg.D2_D1) def xyz_to_rlab(xyz: Vector, env: Environment) -> Vector: """XYZ to RLAB.""" - xyz_ref = alg.dot(env.ram, xyz, dims=alg.D2_D1) + xyz_ref = alg.matmul(env.ram, xyz, dims=alg.D2_D1) xr, yr, zr = [alg.nth_root(c, env.surround) for c in xyz_ref] LR = 100 * yr aR = 430 * (xr - yr) @@ -108,7 +117,16 @@ class RLAB(Lab): ) # Using less than full discounting would require special achromatic handling # to identify achromatic colors as `a == b == 0.0` would no longer be true. - ENV = Environment(WHITE, YN, SURROUND['average'], D['hard-copy']) + ENV = Environment( + # D65 white point. + white=WHITE, + # 1000 lux or `~318.31 cd/m2` + adapting_luminance=YN, + # Average surround + surround='average', + # "Hard copy" or a degree of discount of 1, a.k.a full discounting of illuminant. + discounting='hard-copy' + ) def to_base(self, coords: Vector) -> Vector: """To XYZ from Hunter Lab.""" diff --git a/lib/coloraide/spaces/ryb.py b/lib/coloraide/spaces/ryb.py index 1dd3d46..e8ced83 100644 --- a/lib/coloraide/spaces/ryb.py +++ b/lib/coloraide/spaces/ryb.py @@ -4,6 +4,7 @@ Gosset and Chen http://bahamas10.github.io/ryb/assets/ryb.pdf """ +from __future__ import annotations import math from ..spaces import Regular, Space from .. import algebra as alg @@ -76,7 +77,7 @@ def is_achromatic(self, coords: Vector) -> bool: coords = self.to_base(coords) for x in alg.vcross(coords, [1, 1, 1]): - if not alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): + if not math.isclose(0.0, x, abs_tol=1e-5): return False return True diff --git a/lib/coloraide/spaces/srgb/__init__.py b/lib/coloraide/spaces/srgb/__init__.py index 227dc1e..6fa9020 100644 --- a/lib/coloraide/spaces/srgb/__init__.py +++ b/lib/coloraide/spaces/srgb/__init__.py @@ -1,7 +1,6 @@ """sRGB color class.""" -from ...spaces import RGBish, Space -from ...cat import WHITES -from ...channels import Channel, FLG_OPT_PERCENT +from __future__ import annotations +from ..srgb_linear import sRGBLinear from ... import algebra as alg from ...types import Vector import math @@ -43,33 +42,18 @@ def gam_srgb(rgb: Vector) -> Vector: return result -class sRGB(RGBish, Space): +class sRGB(sRGBLinear): """sRGB class.""" BASE = "srgb-linear" NAME = "srgb" - CHANNELS = ( - Channel("r", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT), - Channel("g", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT), - Channel("b", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT) - ) - CHANNEL_ALIASES = { - "red": 'r', - "green": 'g', - "blue": 'b' - } - WHITE = WHITES['2deg']['D65'] - + SERIALIZE = ("srgb",) EXTENDED_RANGE = True - def is_achromatic(self, coords: Vector) -> bool: - """Test if color is achromatic.""" + def linear(self) -> str: + """Return linear version of the RGB (if available).""" - white = [1, 1, 1] - for x in alg.vcross(coords, white): - if not alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): - return False - return True + return self.BASE def from_base(self, coords: Vector) -> Vector: """From sRGB Linear to sRGB.""" diff --git a/lib/coloraide/spaces/srgb/css.py b/lib/coloraide/spaces/srgb/css.py index ee79f81..dc75e6e 100644 --- a/lib/coloraide/spaces/srgb/css.py +++ b/lib/coloraide/spaces/srgb/css.py @@ -1,8 +1,9 @@ """sRGB color class.""" +from __future__ import annotations from .. import srgb as base from ...css import parse from ...css import serialize -from typing import Optional, Union, Any, Tuple, TYPE_CHECKING +from typing import Any, Tuple, TYPE_CHECKING, Sequence from ...types import Vector if TYPE_CHECKING: # pragma: no cover @@ -14,18 +15,18 @@ class sRGB(base.sRGB): def to_string( self, - parent: 'Color', + parent: Color, *, - alpha: Optional[bool] = None, - precision: Optional[int] = None, - fit: Union[bool, str] = True, + alpha: bool | None = None, + precision: int | None = None, + fit: bool | str | dict[str, Any] = True, none: bool = False, color: bool = False, hex: bool = False, # noqa: A002 names: bool = False, comma: bool = False, upper: bool = False, - percent: bool = False, + percent: bool | Sequence[bool] = False, compress: bool = False, **kwargs: Any ) -> str: @@ -53,7 +54,7 @@ def match( string: str, start: int = 0, fullmatch: bool = True - ) -> Optional[Tuple[Tuple[Vector, float], int]]: + ) -> Tuple[Tuple[Vector, float], int] | None: """Match a CSS color string.""" return parse.parse_css(self, string, start, fullmatch) diff --git a/lib/coloraide/spaces/srgb_linear.py b/lib/coloraide/spaces/srgb_linear.py index 7ebcc19..16148f7 100644 --- a/lib/coloraide/spaces/srgb_linear.py +++ b/lib/coloraide/spaces/srgb_linear.py @@ -1,8 +1,11 @@ """sRGB Linear color class.""" +from __future__ import annotations from ..cat import WHITES -from .srgb import sRGB +from ..spaces import RGBish, Space +from ..channels import Channel from .. import algebra as alg from ..types import Vector +import math RGB_TO_XYZ = [ @@ -25,22 +28,40 @@ def lin_srgb_to_xyz(rgb: Vector) -> Vector: D65 (no chromatic adaptation) """ - return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + return alg.matmul(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_srgb(xyz: Vector) -> Vector: """Convert XYZ to linear-light sRGB.""" - return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + return alg.matmul(XYZ_TO_RGB, xyz, dims=alg.D2_D1) -class sRGBLinear(sRGB): +class sRGBLinear(RGBish, Space): """sRGB linear.""" BASE = 'xyz-d65' NAME = "srgb-linear" - SERIALIZE = ("srgb-linear",) WHITE = WHITES['2deg']['D65'] + CHANNELS = ( + Channel("r", 0.0, 1.0, bound=True), + Channel("g", 0.0, 1.0, bound=True), + Channel("b", 0.0, 1.0, bound=True) + ) + CHANNEL_ALIASES = { + "red": 'r', + "green": 'g', + "blue": 'b' + } + + def is_achromatic(self, coords: Vector) -> bool: + """Test if color is achromatic.""" + + white = [1, 1, 1] + for x in alg.vcross(coords, white): + if not math.isclose(0.0, x, abs_tol=1e-5): + return False + return True def to_base(self, coords: Vector) -> Vector: """To XYZ from sRGB Linear.""" diff --git a/lib/coloraide/spaces/ucs.py b/lib/coloraide/spaces/ucs.py index 921fab9..34e3416 100644 --- a/lib/coloraide/spaces/ucs.py +++ b/lib/coloraide/spaces/ucs.py @@ -3,6 +3,7 @@ http://en.wikipedia.org/wiki/CIE_1960_color_space#Relation_to_CIE_XYZ """ +from __future__ import annotations from ..spaces import Space from ..channels import Channel from ..cat import WHITES diff --git a/lib/coloraide/spaces/xyb.py b/lib/coloraide/spaces/xyb.py index e39a8c5..3b64310 100644 --- a/lib/coloraide/spaces/xyb.py +++ b/lib/coloraide/spaces/xyb.py @@ -3,12 +3,12 @@ https://ds.jpeg.org/whitepapers/jpeg-xl-whitepaper.pdf """ +from __future__ import annotations from .. import algebra as alg from ..spaces import Space, Labish from ..types import Vector from ..cat import WHITES from ..channels import Channel, FLG_MIRROR_PERCENT -from typing import Tuple BIAS = 0.00379307325527544933 BIAS_CBRT = alg.nth_root(BIAS, 3) @@ -48,9 +48,9 @@ def rgb_to_xyb(rgb: Vector) -> Vector: """Linear sRGB to XYB.""" - return alg.dot( + return alg.matmul( XYB_LMS_TO_XYB, - [alg.nth_root(c + BIAS, 3) - BIAS_CBRT for c in alg.dot(LRGB_TO_LMS, rgb, dims=alg.D2_D1)], + [alg.nth_root(c + BIAS, 3) - BIAS_CBRT for c in alg.matmul(LRGB_TO_LMS, rgb, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -62,9 +62,9 @@ def xyb_to_rgb(xyb: Vector) -> Vector: if not any(xyb): return [0.0] * 3 - return alg.dot( + return alg.matmul( LMS_TO_LRGB, - [(c + BIAS_CBRT) ** 3 - BIAS for c in alg.dot(XYB_TO_XYB_LMS, xyb, dims=alg.D2_D1)], + [(c + BIAS_CBRT) ** 3 - BIAS for c in alg.matmul(XYB_TO_XYB_LMS, xyb, dims=alg.D2_D1)], dims=alg.D2_D1 ) @@ -82,7 +82,7 @@ class XYB(Labish, Space): Channel("b", -0.45, 0.45, flags=FLG_MIRROR_PERCENT) ) - def names(self) -> Tuple[str, ...]: + def names(self) -> tuple[str, ...]: """Return Lab-ish names in the order L a b.""" channels = self.channels diff --git a/lib/coloraide/spaces/xyy.py b/lib/coloraide/spaces/xyy.py index e079ed7..66234e5 100644 --- a/lib/coloraide/spaces/xyy.py +++ b/lib/coloraide/spaces/xyy.py @@ -3,6 +3,7 @@ https://en.wikipedia.org/wiki/CIE_1931_color_space#CIE_xy_chromaticity_diagram_and_the_CIE_xyY_color_space """ +from __future__ import annotations from ..spaces import Space from ..channels import Channel from ..cat import WHITES @@ -10,7 +11,6 @@ from ..types import Vector from .. import algebra as alg import math -from typing import Tuple class xyY(Space): @@ -29,12 +29,11 @@ class xyY(Space): def is_achromatic(self, coords: Vector) -> bool: """Test if color is achromatic.""" - if alg.isclose(0.0, coords[-1], abs_tol=1e-4, dims=alg.SC): + if math.isclose(0.0, coords[-1], abs_tol=1e-4): return True - for x in alg.vcross(coords[:-1], self.WHITE): - if not math.isclose(0.0, x, abs_tol=1e-6): - return False + if not math.isclose(0.0, alg.vcross(coords[:-1], self.WHITE), abs_tol=1e-6): + return False return True def to_base(self, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/xyz_d50.py b/lib/coloraide/spaces/xyz_d50.py index 7f3087b..9d69c1b 100644 --- a/lib/coloraide/spaces/xyz_d50.py +++ b/lib/coloraide/spaces/xyz_d50.py @@ -1,4 +1,5 @@ """XYZ class.""" +from __future__ import annotations from ..cat import WHITES from .xyz_d65 import XYZD65 diff --git a/lib/coloraide/spaces/xyz_d65.py b/lib/coloraide/spaces/xyz_d65.py index 8b9d3bb..c12f42d 100644 --- a/lib/coloraide/spaces/xyz_d65.py +++ b/lib/coloraide/spaces/xyz_d65.py @@ -1,4 +1,5 @@ """XYZ D65 class.""" +from __future__ import annotations import math from ..spaces import Space, RGBish from ..cat import WHITES @@ -6,7 +7,6 @@ from .. import util from .. import algebra as alg from ..types import Vector -from typing import Tuple class XYZD65(RGBish, Space): @@ -14,7 +14,7 @@ class XYZD65(RGBish, Space): BASE = "xyz-d65" NAME = "xyz-d65" - SERIALIZE = ("xyz-d65", 'xyz') # type: Tuple[str, ...] + SERIALIZE = ("xyz-d65", 'xyz') # type: tuple[str, ...] CHANNELS = ( Channel("x", 0.0, 1.0), Channel("y", 0.0, 1.0), @@ -26,7 +26,7 @@ def is_achromatic(self, coords: Vector) -> bool: """Is achromatic.""" for x in alg.vcross(coords, util.xy_to_xyz(self.white())): - if not alg.isclose(0.0, x, abs_tol=1e-5, dims=alg.SC): + if not math.isclose(0.0, x, abs_tol=1e-5): return False return True diff --git a/lib/coloraide/spaces/zcam_jmh.py b/lib/coloraide/spaces/zcam_jmh.py new file mode 100644 index 0000000..a793eeb --- /dev/null +++ b/lib/coloraide/spaces/zcam_jmh.py @@ -0,0 +1,449 @@ +""" +ZCAM. + +``` +- ZCAM: https://opg.optica.org/oe/fulltext.cfm?uri=oe-29-4-6036&id=447640. +- Supplemental ZCAM (inverse transform): https://opticapublishing.figshare.com/articles/journal_contribution/\ + Supplementary_document_for_ZCAM_a_psychophysical_model_for_colour_appearance_prediction_-_5022171_pdf/13640927. +- Two-stage chromatic adaptation by Qiyan Zhai and Ming R. Luo using CAM02: https://opg.optica.org/oe/\ + fulltext.cfm?uri=oe-26-6-7724&id=383537 +``` +""" +from __future__ import annotations +import math +import bisect +from .. import util +from .. import algebra as alg +from ..spaces import Space +from ..cat import WHITES +from ..channels import Channel, FLG_ANGLE +from ..types import Vector, VectorLike +from .lch import LCh, ACHROMATIC_THRESHOLD +from .jzazbz import izazbz_to_xyz_d65, xyz_d65_to_izazbz +from .. import cat + +DEF_ILLUMINANT_BI = util.xyz_to_absxyz(util.xy_to_xyz(cat.WHITES['2deg']['E']), yw=100.0) +CAT02 = cat.CAT02.MATRIX +CAT02_INV = alg.inv(CAT02) + +# ZCAM uses a slightly different matrix than Jzazbz +# It updates how `Iz` is calculated. +LMS_P_TO_IZAZBZ = [ + [0.0, 1.0, 0.0], + [3.524, -4.066708, 0.542708], + [0.199076, 1.096799, -1.295875] +] +IZAZBZ_TO_LMS_P = alg.inv(LMS_P_TO_IZAZBZ) + +SURROUND = { + 'dark': (0.8, 0.525, 0.8), + 'dim': (0.9, 0.59, 0.9), + 'average': (1, 0.69, 1) +} + +HUE_QUADRATURE = { + # Red, Yellow, Green, Blue, Red + "h": (33.44, 89.29, 146.30, 238.36, 393.44), + "e": (0.68, 0.64, 1.52, 0.77, 0.68), + "H": (0.0, 100.0, 200.0, 300.0, 400.0) +} + + +def hue_quadrature(h: float) -> float: + """ + Hue to hue quadrature. + + https://onlinelibrary.wiley.com/doi/pdf/10.1002/col.22324 + """ + + hp = util.constrain_hue(h) + if hp <= HUE_QUADRATURE['h'][0]: + hp += 360 + + i = bisect.bisect_left(HUE_QUADRATURE['h'], hp) - 1 + hi, hii = HUE_QUADRATURE['h'][i:i + 2] + ei, eii = HUE_QUADRATURE['e'][i:i + 2] + Hi = HUE_QUADRATURE['H'][i] + + t = (hp - hi) / ei + return Hi + (100 * t) / (t + (hii - hp) / eii) + + +def inv_hue_quadrature(Hz: float) -> float: + """Hue quadrature to hue.""" + + Hp = (Hz % 400 + 400) % 400 + i = math.floor(0.01 * Hp) + Hp = Hp % 100 + hi, hii = HUE_QUADRATURE['h'][i:i + 2] + ei, eii = HUE_QUADRATURE['e'][i:i + 2] + + return util.constrain_hue((Hp * (eii * hi - ei * hii) - 100 * hi * eii) / (Hp * (eii - ei) - 100 * eii)) + + +def adapt( + xyz_b: Vector, + xyz_wb: Vector, + xyz_wd: Vector, + db: float, + dd: float, + xyz_wo: Vector = DEF_ILLUMINANT_BI +) -> Vector: + """ + Use 2 step chromatic adaptation by Qiyan Zhai and Ming R. Luo using CAM02. + + https://opg.optica.org/oe/fulltext.cfm?uri=oe-26-6-7724&id=383537 + + `xyz_b`: the sample color + `xyz_wb`: input illuminant of the sample color + `xyz_wd`: output illuminant + `xyz_wo`: the baseline illuminant, by default we use equal energy. + """ + + yb = xyz_wb[1] / xyz_wo[1] + yd = xyz_wd[1] / xyz_wo[1] + + rgb_b = alg.dot(CAT02, xyz_b, dims=alg.D2_D1) + rgb_wb = alg.dot(CAT02, xyz_wb, dims=alg.D2_D1) + rgb_wd = alg.dot(CAT02, xyz_wd, dims=alg.D2_D1) + rgb_wo = alg.dot(CAT02, xyz_wo, dims=alg.D2_D1) + + d_rgb_wb = alg.add( + alg.multiply(db * yb, alg.divide(rgb_wo, rgb_wb, dims=alg.D1), dims=alg.SC_D1), + 1 - db, + dims=alg.D1_SC + ) + d_rgb_wd = alg.add( + alg.multiply(dd * yd, alg.divide(rgb_wo, rgb_wd, dims=alg.D1), dims=alg.SC_D1), + 1 - dd, + dims=alg.D1_SC + ) + d_rgb = alg.divide(d_rgb_wb, d_rgb_wd, dims=alg.D1) + rgb_d = alg.multiply(d_rgb, rgb_b, dims=alg.D1) + return alg.dot(CAT02_INV, rgb_d, dims=alg.D2_D1) + + +class Environment: + """ + Class to calculate and contain any required environmental data (viewing conditions included). + + While originally for CIECAM models, the following applies to ZCAM as well. + Usage Guidelines for CIECAM97s (Nathan Moroney) + https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf + + white: This is the (x, y) chromaticity points for the white point. ZCAM is designed to use D65. + Generally, D65 should always be used, but we allow the possibility of variants of D65. This should + be the same value as set in the color class `WHITE` value. + + ref_white: The reference white in XYZ scaled by 100. + + adapting_luminance: This is the the luminance of the adapting field. The units are in cd/m2. + The equation is `L = (E * R) / π`, where `E` is the illuminance in lux, `R` is the reflectance, + and `L` is the luminance. If we assume a perfectly reflecting diffuser, `R` is assumed as 1. + For the "gray world" assumption, we must also divide by 5 (or multiply by 0.2 - 20%). + This results in `La = E / π * 0.2`. You can also ignore this gray world assumption converting + lux directly to nits (cd/m2) `lux / π`. + + background_luminance: The background is the region immediately surrounding the stimulus and + for images is the neighboring portion of the image. Generally, this value is set to a value of 20. + This implicitly assumes a gray world assumption. + + surround: The surround is categorical and is defined based on the relationship between the relative + luminance of the surround and the luminance of the scene or image white. While there are 4 defined + surrounds, usually just `average`, `dim`, and `dark` are used. + + Dark | 0% | Viewing film projected in a dark room + Dim | 0% to 20% | Viewing television + Average | > 20% | Viewing surface colors + + discounting: Whether we are discounting the illuminance. Done when eye is assumed to be fully adapted. + """ + + def __init__( + self, + *, + white: VectorLike, + reference_white: VectorLike, + adapting_luminance: float, + background_luminance: float, + surround: str, + discounting: bool + ): + """ + Initialize environmental viewing conditions. + + Using the specified viewing conditions, and general environmental data, + initialize anything that we can ahead of time to speed up the process. + """ + + self.output_white = util.xyz_to_absxyz(util.xy_to_xyz(white), yw=100) + self.ref_white = list(reference_white) + self.surround = surround + self.discounting = discounting + xyz_w = self.ref_white + + # The average luminance of the environment in `cd/m^2cd/m` (a.k.a. nits) + self.la = adapting_luminance + # The relative luminance of the nearby background + self.yb = background_luminance + # Absolute luminance of the reference white. + yw = xyz_w[1] + self.fb = math.sqrt(self.yb / yw) + self.fl = 0.171 * alg.nth_root(self.la, 3) * (1 - math.exp((-48 / 9) * self.la)) + + # Surround: dark, dim, and average + f, self.c, _ = SURROUND[self.surround] + self.fs = self.c + self.epsilon = 3.7035226210190005e-11 + self.rho = 1.7 * 2523 / (2 ** 5) + self.b = 1.15 + self.g = 0.66 + + self.izw = xyz_d65_to_izazbz(xyz_w, LMS_P_TO_IZAZBZ, self.rho)[0] - self.epsilon + self.qzw = ( + 2700 * alg.spow(self.izw, (1.6 * self.fs) / (self.fb ** 0.12)) * + ((self.fs ** 2.2) * (self.fb ** 0.5) * (self.fl ** 0.2)) + ) + + # Degree of adaptation calculating if not discounting illuminant (assumed eye is fully adapted) + self.d = alg.clamp(f * (1 - 1 / 3.6 * math.exp((-self.la - 42) / 92)), 0, 1) if not self.discounting else 1 + + +def zcam_to_xyz_d65( + Jz: float | None = None, + Cz: float | None = None, + hz: float | None = None, + Qz: float | None = None, + Mz: float | None = None, + Sz: float | None = None, + Vz: float | None = None, + Kz: float | None = None, + Wz: float | None = None, + Hz: float | None = None, + env: Environment | None = None +) -> Vector: + """ + From ZCAM to XYZ. + + Reverse calculation can actually be obtained from a small subset of the ZCAM components + Really, only one suitable value is needed for each type of attribute: (lightness/brightness), + (chroma/colorfulness/saturation), (hue/hue quadrature). If more than one for a given + category is given, we will fail as we have no idea which is the right one to use. Also, + if none are given, we must fail as well as there is nothing to calculate with. + """ + + # These check ensure one, and only one attribute for a given category is provided. + if not ((Jz is not None) ^ (Qz is not None)): + raise ValueError("Conversion requires one and only one: 'Jz' or 'Qz'") + + if not ( + (Cz is not None) ^ (Mz is not None) ^ (Sz is not None) ^ (Vz is not None) ^ (Kz is not None) ^ (Wz is not None) + ): + raise ValueError("Conversion requires one and only one: 'Cz', 'Mz', 'Sz', 'Vz', 'Kz', or 'Wz'") + + # Hue is absolutely required + if not ((hz is not None) ^ (Hz is not None)): + raise ValueError("Conversion requires one and only one: 'hz' or 'Hz'") + + # We need viewing conditions + if env is None: + raise ValueError("No viewing conditions/environment provided") + + # Black + if Jz == 0.0 or Qz == 0.0: + return [0.0, 0.0, 0.0] + + # Break hue into Cartesian components + h_rad = 0.0 + if hz is None: + hz = inv_hue_quadrature(Hz) # type: ignore[arg-type] + h_rad = math.radians(hz % 360) + cos_h = math.cos(h_rad) + sin_h = math.sin(h_rad) + hp = hz + if hp <= HUE_QUADRATURE['h'][0]: + hp += 360 + ez = 1.015 + math.cos(math.radians(89.038 + hp)) + + # Calculate `iz` from one of the lightness derived coordinates. + if Qz is None: + Qz = (Jz * 0.01) * env.qzw # type: ignore[operator] + + if Jz is None: + Jz = 100 * (Qz / env.qzw) + + iz = alg.nth_root( + Qz / ((env.fs ** 2.2) * (env.fb ** 0.5) * (env.fl ** 0.2) * 2700), (1.6 * env.fs) / (env.fb ** 0.12) + ) + + # Calculate `Mz` from the various chroma like parameters. + if Sz is not None: + Cz = Qz * Sz ** 2 / (100 * env.qzw * env.fl ** 1.2) + elif Vz is not None: + Cz = math.sqrt((Vz ** 2 - (Jz - 58) ** 2) / 3.4) + elif Kz is not None: + Cz = math.sqrt((((Kz - 100) / - 0.8) ** 2 - (Jz ** 2)) / 8) + elif Wz is not None: + Cz = math.sqrt((Wz - 100) ** 2 - (100 - Jz) ** 2) + + if Cz is not None: + Mz = (Cz / 100) * env.qzw + + Czp = alg.spow( + (Mz * (env.izw ** (0.78)) * (env.fb ** 0.1)) / (100 * (ez ** 0.068) * (env.fl ** 0.2)), + 1.0 / 0.37 / 2 + ) + + # Convert back to XYZ + az, bz = cos_h * Czp, sin_h * Czp + iz += env.epsilon + xyz_abs = izazbz_to_xyz_d65([iz, az, bz], IZAZBZ_TO_LMS_P, env.rho) + + return util.absxyz_to_xyz(adapt(xyz_abs, env.output_white, env.ref_white, env.d, env.d)) + + +def xyz_d65_to_zcam(xyzd65: Vector, env: Environment, calc_hue_quadrature: bool = False) -> Vector: + """From XYZ to ZCAM.""" + + # Steps 4 - 7 + iz, az, bz = xyz_d65_to_izazbz( + adapt(util.xyz_to_absxyz(xyzd65), env.ref_white, env.output_white, env.d, env.d), + LMS_P_TO_IZAZBZ, + env.rho + ) + + # Step 8 + iz -= env.epsilon + + # Step 9 + hz = util.constrain_hue(math.degrees(math.atan2(bz, az))) + + # Step 10 + Hz = hue_quadrature(hz) if calc_hue_quadrature else alg.NaN + + # Step 11 + hp = hz + if hp <= HUE_QUADRATURE['h'][0]: + hp += 360 + ez = 1.015 + math.cos(math.radians(89.038 + hp)) + + # Step 12 + Qz = ( + 2700 * alg.spow(iz, (1.6 * env.fs) / (env.fb ** 0.12)) * + ((env.fs ** 2.2) * (env.fb ** 0.5) * (env.fl ** 0.2)) + ) + + # Step 13 + Jz = 100 * (Qz / env.qzw) + + # Step 14 + Mz = ( + 100 * ((az ** 2 + bz ** 2) ** (0.37)) * + ((alg.spow(ez, 0.068) * (env.fl ** 0.2)) / ((env.fb ** 0.1) * alg.spow(env.izw, 0.78))) + ) + + # Step 15 + Cz = 100 * (Mz / env.qzw) + + # Step 16 + Sz = 100 * (env.fl ** 0.6) * math.sqrt(Mz / Qz) if Qz else 0.0 + + # Step 17 + Vz = math.sqrt((Jz - 58) ** 2 + 3.4 * (Cz ** 2)) + + # Step 18 + Kz = 100 - 0.8 * math.sqrt(Jz ** 2 + 8 * (Cz ** 2)) + + # Step 19 + Wz = 100 - math.sqrt((100 - Jz) ** 2 + Cz ** 2) + + return [Jz, Cz, hz, Qz, Mz, Sz, Vz, Kz, Wz, Hz] + + +def xyz_d65_to_zcam_jmh(xyzd65: Vector, env: Environment) -> Vector: + """XYZ to ZCAM JMh.""" + + zcam = xyz_d65_to_zcam(xyzd65, env) + Jz, Mz, hz = zcam[0], zcam[4], zcam[2] + return [Jz, Mz, hz] + + +def zcam_jmh_to_xyz_d65(jmh: Vector, env: Environment) -> Vector: + """ZCAM JMh to XYZ.""" + + Jz, Mz, hz = jmh + return zcam_to_xyz_d65(Jz=Jz, Mz=Mz, hz=hz, env=env) + + +class ZCAMJMh(LCh, Space): + """ZCAM class (JMh).""" + + BASE = "xyz-d65" + NAME = "zcam-jmh" + SERIALIZE = ("--zcam-jmh",) + CHANNEL_ALIASES = { + "lightness": "jz", + "colorfulness": 'mz', + "hue": 'hz', + 'j': 'jz', + 'm': "mz", + 'h': 'hz' + } + WHITE = WHITES['2deg']['D65'] + DYNAMIC_RANGE = 'hdr' + + # Assuming sRGB which has a lux of 64 + ENV = Environment( + # D65 white point. + white=WHITE, + # The reference white in XYZ scaled by 100 + reference_white=util.xyz_to_absxyz(util.xy_to_xyz(WHITE), 100), + # Assuming sRGB which has a lux of 64: `((E * R) / PI)` where `R = 1`. + # Divided by 5 (or multiplied by 20%) assuming gray world. + adapting_luminance=64 / math.pi * 0.2, + # 20% relative to an XYZ luminance of 100 (scaled by 100) for the gray world assumption. + background_luminance=20, + # Assume an average surround + surround='average', + # Do not discount illuminant. + discounting=False + ) + CHANNELS = ( + Channel("jz", 0.0, 100.0), + Channel("mz", 0, 60.0), + Channel("hz", 0.0, 360.0, flags=FLG_ANGLE) + ) + + def normalize(self, coords: Vector) -> Vector: + """Normalize.""" + + if coords[1] < 0.0: + return self.from_base(self.to_base(coords)) + coords[2] %= 360.0 + return coords + + def is_achromatic(self, coords: Vector) -> bool | None: + """Check if color is achromatic.""" + + # Account for both positive and negative chroma + return coords[0] == 0 or abs(coords[1]) < ACHROMATIC_THRESHOLD + + def hue_name(self) -> str: + """Hue name.""" + + return "hz" + + def radial_name(self) -> str: + """Radial name.""" + + return "mz" + + def to_base(self, coords: Vector) -> Vector: + """From ZCAM JMh to XYZ.""" + + return zcam_jmh_to_xyz_d65(coords, self.ENV) + + def from_base(self, coords: Vector) -> Vector: + """From XYZ to ZCAM JMh.""" + + return xyz_d65_to_zcam_jmh(coords, self.ENV) diff --git a/lib/coloraide/temperature/__init__.py b/lib/coloraide/temperature/__init__.py index 3ad13db..379256a 100644 --- a/lib/coloraide/temperature/__init__.py +++ b/lib/coloraide/temperature/__init__.py @@ -1,7 +1,8 @@ """Temperature plugin.""" +from __future__ import annotations from abc import ABCMeta, abstractmethod from ..types import Plugin, Vector -from typing import TYPE_CHECKING, Union, Optional, Any, Type +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -13,24 +14,24 @@ class CCT(Plugin, metaclass=ABCMeta): NAME = '' @abstractmethod - def to_cct(self, color: 'Color', **kwargs: Any) -> Vector: + def to_cct(self, color: Color, **kwargs: Any) -> Vector: """Calculate a color's CCT.""" @abstractmethod def from_cct( self, - color: Type['Color'], + color: type[Color], space: str, kelvin: float, duv: float, scale: bool, - scale_space: Optional[str], + scale_space: str | None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Calculate a color that satisfies the CCT.""" -def cct(name: Optional[str], color: Union[Type['Color'], 'Color']) -> CCT: +def cct(name: str | None, color: type[Color] | Color) -> CCT: """Get the appropriate contrast plugin.""" if name is None: diff --git a/lib/coloraide/temperature/ohno_2013.py b/lib/coloraide/temperature/ohno_2013.py index 7ed86eb..7b26dac 100644 --- a/lib/coloraide/temperature/ohno_2013.py +++ b/lib/coloraide/temperature/ohno_2013.py @@ -3,6 +3,7 @@ https://www.researchgate.net/publication/263373260_Practical_Use_and_Calculation_of_CCT_and_Duv """ +from __future__ import annotations import math from . import planck from .. import cat @@ -11,7 +12,7 @@ from .. import algebra as alg from ..temperature import CCT from ..types import Vector, VectorLike -from typing import TYPE_CHECKING, Any, List, Tuple, Dict, Optional, Type +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -30,7 +31,7 @@ class BlackBodyCurve: def __init__( self, - cmfs: Dict[int, Tuple[float, float, float]] = cmfs.CIE_1931_2DEG, + cmfs: dict[int, tuple[float, float, float]] = cmfs.CIE_1931_2DEG, white: VectorLike = cat.WHITES['2deg']['D65'], planck_step: int = 5, chromaticity: str = 'uv-1960' @@ -83,7 +84,7 @@ def __init__( points.append([u, v]) self.spline2 = alg.interpolate(points, method='catrom') - def scale(self, point: float, domain: List[float]) -> float: + def scale(self, point: float, domain: Vector) -> float: """Scale the temperature point to match the range 0 - 1.""" # Extrapolation @@ -148,7 +149,7 @@ class Ohno2013(CCT): def __init__( self, - cmfs: Dict[int, Tuple[float, float, float]] = cmfs.CIE_1931_2DEG, + cmfs: dict[int, tuple[float, float, float]] = cmfs.CIE_1931_2DEG, white: VectorLike = cat.WHITES['2deg']['D65'], planck_step: int = 5 ): @@ -159,7 +160,7 @@ def __init__( def to_cct( self, - color: 'Color', + color: Color, start: float = 1000, end: float = 100000, samples: int = 10, @@ -172,12 +173,12 @@ def to_cct( u, v = color.split_chromaticity(self.CHROMATICITY)[:-1] last = samples - 1 index = 0 - table = [] # type: List[Tuple[float, float, float, float]] + table = [] # type: list[tuple[float, float, float, float]] # Each iteration we narrow the range until we are close enough for _ in range(iterations): table.clear() - lowest = alg.inf + lowest = math.inf index = 0 # Generate the Planckian table while tracking lowest distance @@ -251,14 +252,14 @@ def to_cct( def from_cct( self, - color: Type['Color'], + color: type[Color], space: str, kelvin: float, duv: float, scale: bool, - scale_space: Optional[str], + scale_space: str | None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Calculate a color that satisfies the CCT using Planck's law.""" u0, v0 = self.blackbody(kelvin, exact=True) diff --git a/lib/coloraide/temperature/planck.py b/lib/coloraide/temperature/planck.py index 0f1f9b7..5b48bc8 100644 --- a/lib/coloraide/temperature/planck.py +++ b/lib/coloraide/temperature/planck.py @@ -3,10 +3,10 @@ https://en.wikipedia.org/wiki/Planckian_locus#The_Planckian_locus_in_the_XYZ_color_space """ +from __future__ import annotations import math from ..types import VectorLike, Vector from .. import util -from typing import Dict, Tuple # Constants for Planck's Law # Precise calculation @@ -24,7 +24,7 @@ def temp_to_xy_planckian_locus( temp: float, - cmfs: Dict[int, Tuple[float, float, float]], + cmfs: dict[int, tuple[float, float, float]], white: VectorLike, start: int = 360, end: int = 830, diff --git a/lib/coloraide/temperature/robertson_1968.py b/lib/coloraide/temperature/robertson_1968.py index 417c3b9..d67204b 100644 --- a/lib/coloraide/temperature/robertson_1968.py +++ b/lib/coloraide/temperature/robertson_1968.py @@ -6,6 +6,7 @@ - https://en.wikipedia.org/wiki/Correlated_color_temperature#Robertson's_method - http://www.brucelindbloom.com/index.html?Math.html """ +from __future__ import annotations import math from . import planck from .. import algebra as alg @@ -14,7 +15,7 @@ from .. import cmfs from ..temperature import CCT from ..types import Vector, VectorLike -from typing import TYPE_CHECKING, Any, Tuple, Dict, List, Optional, Type +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover from ..color import Color @@ -33,7 +34,7 @@ class Robertson1968(CCT): def __init__( self, - cmfs: Dict[int, Tuple[float, float, float]] = cmfs.CIE_1931_2DEG, + cmfs: dict[int, tuple[float, float, float]] = cmfs.CIE_1931_2DEG, white: VectorLike = cat.WHITES['2deg']['D65'], mired: VectorLike = MIRED_EXTENDED, sigfig: int = 5, @@ -46,12 +47,12 @@ def __init__( def generate_table( self, - cmfs: Dict[int, Tuple[float, float, float]], + cmfs: dict[int, tuple[float, float, float]], white: VectorLike, mired: VectorLike, sigfig: int, planck_step: int, - ) -> List[Tuple[float, float, float, float]]: + ) -> list[tuple[float, float, float, float]]: """ Generate the necessary table for the Robertson1968 method. @@ -69,7 +70,7 @@ def generate_table( """ xyzw = util.xy_to_xyz(white) - table = [] # type: List[Tuple[float, float, float, float]] + table = [] # type: list[tuple[float, float, float, float]] to_uv = util.xy_to_uv_1960 if self.CHROMATICITY == 'uv-1960' else util.xy_to_uv for t in mired: uv1 = to_uv(planck.temp_to_xy_planckian_locus(1e6 / (t - 0.01), cmfs, xyzw, step=planck_step)) @@ -99,7 +100,7 @@ def generate_table( table.append((t, uv[0], uv[1], m)) return table - def to_cct(self, color: 'Color', **kwargs: Any) -> Vector: + def to_cct(self, color: Color, **kwargs: Any) -> Vector: """Calculate a color's CCT.""" u, v = color.split_chromaticity(self.CHROMATICITY)[:-1] @@ -132,7 +133,7 @@ def to_cct(self, color: 'Color', **kwargs: Any) -> Vector: # Calculate the temperature, if the mired value is zero # assume the maximum temperature of 100000K. mired = alg.lerp(previous[0], current[0], factor) - temp = 1.0E6 / mired if mired > 0 else alg.inf + temp = 1.0E6 / mired if mired > 0 else math.inf # Interpolate the slope vectors dup = 1 / previous_denom @@ -160,14 +161,14 @@ def to_cct(self, color: 'Color', **kwargs: Any) -> Vector: def from_cct( self, - color: Type['Color'], + color: type[Color], space: str, kelvin: float, duv: float, scale: bool, - scale_space: Optional[str], + scale_space: str | None, **kwargs: Any - ) -> 'Color': + ) -> Color: """Calculate a color that satisfies the CCT.""" # Find inverse temperature to use as index. diff --git a/lib/coloraide/types.py b/lib/coloraide/types.py index c7ae45a..2659060 100644 --- a/lib/coloraide/types.py +++ b/lib/coloraide/types.py @@ -1,4 +1,5 @@ """Typing.""" +from __future__ import annotations from typing import Union, Any, Mapping, Sequence, List, Tuple, TypeVar, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover @@ -9,12 +10,25 @@ # Vectors, Matrices, and Arrays are assumed to be mutable lists Vector = List[float] Matrix = List[Vector] -Array = Union[Matrix, Vector] +Tensor = List[List[List[Union[float, Any]]]] +Array = Union[Matrix, Vector, Tensor] # Anything that resembles a sequence will be considered "like" one of our types above VectorLike = Sequence[float] MatrixLike = Sequence[VectorLike] -ArrayLike = Union[VectorLike, MatrixLike] +TensorLike = Sequence[Sequence[Sequence[Union[float, Any]]]] +ArrayLike = Union[VectorLike, MatrixLike, TensorLike] + +# Vectors, Matrices, and Arrays of various, specific types +VectorBool = List[bool] +MatrixBool = List[VectorBool] +TensorBool = List[List[List[Union[bool, Any]]]] +ArrayBool = Union[MatrixBool, VectorBool, TensorBool] + +VectorInt = List[int] +MatrixInt = List[VectorInt] +TensorInt = List[List[List[Union[int, Any]]]] +ArrayInt = Union[MatrixInt, VectorInt, TensorInt] # General algebra types Shape = Tuple[int, ...] @@ -24,7 +38,7 @@ # For times when we must explicitly say we support `int` and `float` SupportsFloatOrInt = TypeVar('SupportsFloatOrInt', float, int) -MathType= TypeVar('MathType', float, VectorLike, MatrixLike) +MathType = TypeVar('MathType', float, VectorLike, MatrixLike, TensorLike) class Plugin: diff --git a/lib/coloraide/util.py b/lib/coloraide/util.py index 51fd6fd..1d4b896 100644 --- a/lib/coloraide/util.py +++ b/lib/coloraide/util.py @@ -1,7 +1,10 @@ """Utilities.""" +from __future__ import annotations import math +from functools import wraps from . import algebra as alg from .types import Vector, VectorLike +from typing import Any, Callable DEF_PREC = 5 DEF_FIT_TOLERANCE = 0.000075 @@ -16,13 +19,7 @@ DEF_CHROMATIC_ADAPTATION = "bradford" DEF_CONTRAST = "wcag21" DEF_CCT = "robertson-1968" - -# Maximum luminance in PQ is 10,000 cd/m^2 -# Relative XYZ has Y=1 for media white -# BT.2048 says media white Y=203 at PQ 58 -# -# This is confirmed here: https://www.itu.int/dms_pub/itu-r/opb/rep/R-REP-BT.2408-3-2019-PDF-E.pdf -YW = 203 +DEF_INTERPOLATOR = "linear" # PQ Constants # https://en.wikipedia.org/wiki/High-dynamic-range_video#Perceptual_quantizer @@ -112,9 +109,28 @@ def pq_st2084_oetf( adjusted = [] for c in values: - c = alg.npow(c / 10000, m1) - r = (c1 + c2 * c) / (1 + c3 * c) - adjusted.append(alg.npow(r, m2)) + c = alg.spow(c / 10000, m1) + adjusted.append(alg.spow((c1 + c2 * c) / (1 + c3 * c), m2)) + return adjusted + + +def pq_st2084_eotf( + values: VectorLike, + c1: float = C1, + c2: float = C2, + c3: float = C3, + m1: float = M1, + m2: float = M2 +) -> Vector: + """Perceptual quantizer (SMPTE ST 2084) - EOTF.""" + + im1 = 1 / m1 + im2 = 1 / m2 + + adjusted = [] + for c in values: + c = alg.spow(c, im2) + adjusted.append(10000 * alg.spow(max((c - c1), 0) / (c2 - c3 * c), im1)) return adjusted @@ -138,37 +154,28 @@ def rgb_scale(vec: VectorLike) -> Vector: return [v / m if m else v for v in vec] -def pq_st2084_eotf( - values: VectorLike, - c1: float = C1, - c2: float = C2, - c3: float = C3, - m1: float = M1, - m2: float = M2 -) -> Vector: - """Perceptual quantizer (SMPTE ST 2084) - EOTF.""" +def scale100(coords: Vector) -> Vector: + """Scale from 1 to 100.""" - im1 = 1 / m1 - im2 = 1 / m2 + return [c * 100 for c in coords] - adjusted = [] - for c in values: - c = alg.npow(c, im2) - r = (c - c1) / (c2 - c3 * c) - adjusted.append(10000 * alg.npow(r, im1)) - return adjusted + +def scale1(coords: Vector) -> Vector: + """Scale from 1 to 100.""" + + return [c * 0.01 for c in coords] -def xyz_to_absxyz(xyzd65: VectorLike, yw: float = YW) -> Vector: +def xyz_to_absxyz(xyzd65: VectorLike, yw: float = 100) -> Vector: """XYZ to Absolute XYZ.""" - return [max(c * yw, 0) for c in xyzd65] + return [c * yw for c in xyzd65] -def absxyz_to_xyz(absxyzd65: VectorLike, yw: float = YW) -> Vector: +def absxyz_to_xyz(absxyzd65: VectorLike, yw: float = 100) -> Vector: """Absolute XYZ to XYZ.""" - return [max(c / yw, 0) for c in absxyzd65] + return [c / yw for c in absxyzd65] def constrain_hue(hue: float) -> float: @@ -200,5 +207,30 @@ def fmt_float(f: float, p: int = 0, percent: float = 0.0, offset: float = 0.0) - value = alg.round_to((f + offset) / (percent * 0.01) if percent else f, p) if p == -1: - p = 17 # double precision - return ('{{:{}{}g}}{}'.format('' if abs(value) >= 10 ** p else '.', p, '%' if percent else '')).format(value) + p = 17 # ~double precision + + # Calculate actual print decimal precision + whole = int(value) + if whole: + whole = int(math.log10(-whole if whole < 0 else whole)) + 1 + if p: + p = max(p - whole, 0) + + # Format the string + s = '{{:{}{}f}}'.format('' if whole >= p else '.', p).format(value).rstrip('0').rstrip('.') + return s + '%' if percent else s + + +def debug(func: Callable[..., Any]) -> Callable[..., Any]: # pragma: no cover + """Intercept function call and print arguments and results.""" + + @wraps(func) + def _wrapper(*args: Any, **kwargs: Any) -> Any: + """Print debug information about the function.""" + + print(f" Calling '{func.__name__}' with args={args} and kwargs={kwargs}") + result = func(*args, **kwargs) + print(f" '{func.__name__}' returned {result}") + return result + + return _wrapper diff --git a/tox.ini b/tox.ini index 1cbbd8e..5dfa779 100644 --- a/tox.ini +++ b/tox.ini @@ -28,6 +28,6 @@ commands= flake8 "{toxinidir}" [flake8] -ignore=D202,D203,D401,W504,E741,N818 +ignore=D202,D203,D401,W504,E741,N818,A005 max-line-length=120 exclude=site/*.py,.tox/*,lib/coloraide/*,lib/coloraide_extras From 7eb45dbb7c59f8e2ead17a3bc7485efc546e4e48 Mon Sep 17 00:00:00 2001 From: Serhii Hospodarchuk <69005134+gosxrgxx@users.noreply.github.com> Date: Mon, 20 May 2024 19:06:20 +0300 Subject: [PATCH 5/8] Add color rule for INI syntax (#264) * Add color rule for INI syntax * Delete `color_trigger` * Remove dangling commas --- color_helper.sublime-settings | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/color_helper.sublime-settings b/color_helper.sublime-settings index 554df86..b90b6d1 100755 --- a/color_helper.sublime-settings +++ b/color_helper.sublime-settings @@ -513,6 +513,14 @@ "scanning": ["constant.other.color"], "color_class": "ass_abgr", "color_trigger": "(?:&H|(?<=\\\\[1-4]c)|(?<=\\\\c))[0-9a-fA-F]" + }, + { + // INI (based on https://packagecontrol.io/packages/INI) + "name": "INI", + "syntax_files": ["INI/INI"], + "base_scopes": ["source.ini"], + "color_class": "css-level-4", + "scanning": ["meta.mapping.value.ini"] } ], From 17d1a26367b53026215659b0dc8a7e3758042094 Mon Sep 17 00:00:00 2001 From: Isaac Muse Date: Mon, 20 May 2024 10:21:44 -0600 Subject: [PATCH 6/8] Bump version and update changelog (#268) --- CHANGES.md | 858 +++++++++++++++++++++++---------------------- messages.json | 2 +- messages/recent.md | 11 +- support.py | 2 +- 4 files changed, 439 insertions(+), 434 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 5b6cf18..ae6d2eb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,74 +1,76 @@ # ColorHelper -## 6.4 +## 6.4.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. ## 6.3.2 -- **FIX**: Fix missing requirement for `math.isclose` in ColorAide - (Python 3.3). -- **FIX**: Do not pad preview by default due to performance impact. +- **FIX**: Fix missing requirement for `math.isclose` in ColorAide + (Python 3.3). +- **FIX**: Do not pad preview by default due to performance impact. ## 6.3.1 -- **FIX**: Update to ColorAide 2.9.1 which uses the exact CSS HWB - algorithm instead of the HSV -> HWB algorithm. +- **FIX**: Update to ColorAide 2.9.1 which uses the exact CSS HWB + algorithm instead of the HSV -> HWB algorithm. ## 6.3.0 -- **NEW**: Upgrade to ColorAide 2.9. -- **FIX**: Fix some issues with blend and contrast tool. +- **NEW**: Upgrade to ColorAide 2.9. +- **FIX**: Fix some issues with blend and contrast tool. ## 6.2.2 -- **FIX**: Remove regression fix that was fixing a false issue. +- **FIX**: Remove regression fix that was fixing a false issue. ## 6.2.1 -- **FIX**: Fix issue where recent changes for "on activated" caused +- **FIX**: Fix issue where recent changes for "on activated" caused a regression. ## 6.2.0 -- **NEW**: Since browsers do not and may not introduce Color Level 4 - gamut mapping until some future spec, make gamut mapping approach - configurable. Use clipping by default to match browsers as this is - likely what people expect even if it is not an ideal approach. Use - `gamut_map` in settings option to manually control the approach. -- **NEW**: Upgrade ColorAide to 2.4. -- **NEW**: Previews now run immediately on view activation. -- **NEW**: The sliding preview window has configurable padding to scan - a larger region. -- **FIX**: Fix regression where contrast logic could not adjust to a - given contrast due to a property access. +- **NEW**: Since browsers do not and may not introduce Color Level 4 + gamut mapping until some future spec, make gamut mapping approach + configurable. Use clipping by default to match browsers as this is + likely what people expect even if it is not an ideal approach. Use + `gamut_map` in settings option to manually control the approach. +- **NEW**: Upgrade ColorAide to 2.4. +- **NEW**: Previews now run immediately on view activation. +- **NEW**: The sliding preview window has configurable padding to scan + a larger region. +- **FIX**: Fix regression where contrast logic could not adjust to a + given contrast due to a property access. ## 6.1.2 -- **FIX**: Update to ColorAide 1.7.1. +- **FIX**: Update to ColorAide 1.7.1. ## 6.1.1 -- **FIX**: Fix broken gamut mapping logic after recent port of latest +- **FIX**: Fix broken gamut mapping logic after recent port of latest `coloraide`. ## 6.1.0 -- **NEW**: Update to ColorAide 1.5. -- **FIX**: Fix issue where if a view does not have a syntax it could - cause an exception. +- **NEW**: Update to ColorAide 1.5. +- **FIX**: Fix issue where if a view does not have a syntax it could + cause an exception. ## 6.0.3 -- **FIX**: Fix registration of color spaces in custom color objects. +- **FIX**: Fix registration of color spaces in custom color objects. ## 6.0.2 -- **FIX**: Fix issue where default, dynamic color class wasn't always - properly. +- **FIX**: Fix issue where default, dynamic color class wasn't always + properly. ## 6.0.1 @@ -80,21 +82,21 @@ > but some more unforeseen changes had to be made. This has been a long > road to get the underlying color library to a stable state. > -> - User created custom plugins may need refactoring again, but most -> should be unaffected. -> - If you tweaked the new`add_to_default_spaces`, please compare against -> the default list as some plugins were renamed and user settings may -> need to get updated. Color space plugins that do not properly load -> should show log entries in the console. - -- **NEW**: Upgraded to the stable `coloraide` 1.1. This should hopefully - eliminate API churn as it is now a stable release. -- **NEW**: Log when default color space loading fails. -- **FIX**: Fix color picker slider issue. +> - User created custom plugins may need refactoring again, but most +> should be unaffected. +> - If you tweaked the new`add_to_default_spaces`, please compare against +> the default list as some plugins were renamed and user settings may +> need to get updated. Color space plugins that do not properly load +> should show log entries in the console. + +- **NEW**: Upgraded to the stable `coloraide` 1.1. This should hopefully + eliminate API churn as it is now a stable release. +- **NEW**: Log when default color space loading fails. +- **FIX**: Fix color picker slider issue. ## 5.0.1 -- **FIX**: Fix issue with Sublime ColorMod. +- **FIX**: Fix issue with Sublime ColorMod. ## 5.0.0 @@ -105,68 +107,68 @@ > color classes will need to update the color classes and color spaces > to work with the latest version. -- **NEW**: Upgrade to latest `coloraide`. -- **NEW**: Many new color spaces have been added and can optionally - be included via the new `add_to_default_spaces` option. Some that - were available previously are no longer registered by default. - See `add_to_default_spaces` in the settings file to enable more - spaces. A restart of Sublime Text is required when changing this - setting. -- **NEW**: Add new `add_to_default_spaces` which allows a user to add - NEW color spaces to the default color space so that the new spaces - can be saved and recognized in palettes and other areas of ColorHelper. - Modifying this setting requires a restart of Sublime Text. Custom - color classes should only be used to modifying previously added - color spaces to add to recognized input and output formats. +- **NEW**: Upgrade to latest `coloraide`. +- **NEW**: Many new color spaces have been added and can optionally + be included via the new `add_to_default_spaces` option. Some that + were available previously are no longer registered by default. + See `add_to_default_spaces` in the settings file to enable more + spaces. A restart of Sublime Text is required when changing this + setting. +- **NEW**: Add new `add_to_default_spaces` which allows a user to add + NEW color spaces to the default color space so that the new spaces + can be saved and recognized in palettes and other areas of ColorHelper. + Modifying this setting requires a restart of Sublime Text. Custom + color classes should only be used to modifying previously added + color spaces to add to recognized input and output formats. ## 4.3.1 -- **NEW**: Upgrade underlying `coloraide` library to fix a color parsing - bug. +- **NEW**: Upgrade underlying `coloraide` library to fix a color parsing + bug. ## 4.3.0 -- **NEW**: Upgrade `coloraide`, along with various improvements brings - the new HSLuv color space. -- **NEW**: New `coloraide` enforces the old Lch gamut mapping as some - issues with the CSS recommended Oklch were discovered. -- **NEW**: Add HSLuv based color picker. Can be enabled in the settings. +- **NEW**: Upgrade `coloraide`, along with various improvements brings + the new HSLuv color space. +- **NEW**: New `coloraide` enforces the old Lch gamut mapping as some + issues with the CSS recommended Oklch were discovered. +- **NEW**: Add HSLuv based color picker. Can be enabled in the settings. ## 4.2.0 -- **NEW**: Upgrade `coloraide` library which brings back color mapping - mapping in CIELCH. While interpolation is great in Oklab/Oklch, gamut - mapping with chroma reduction in Oklch has some less desirable corner - cases. Using Oklch is in the early stages in the CSS Color Level 4 spec - and there needs to be more time for this to mature and be tested more. -- **NEW**: `oklab()` and `oklch()` CSS format is now available. This form - is based on the published CSS Level 4 spec and requires lightness to be - a percentage. In the future, it is likely that percentages will be - optional for lightness and could even be applied to some of the other - components, but currently, such changes are in some early drafts and - not currently included in ColorHelper. +- **NEW**: Upgrade `coloraide` library which brings back color mapping + mapping in CIELCH. While interpolation is great in Oklab/Oklch, gamut + mapping with chroma reduction in Oklch has some less desirable corner + cases. Using Oklch is in the early stages in the CSS Color Level 4 spec + and there needs to be more time for this to mature and be tested more. +- **NEW**: `oklab()` and `oklch()` CSS format is now available. This form + is based on the published CSS Level 4 spec and requires lightness to be + a percentage. In the future, it is likely that percentages will be + optional for lightness and could even be applied to some of the other + components, but currently, such changes are in some early drafts and + not currently included in ColorHelper. ## 4.1.1 -- **FIX**: Fix palette update logic that would not properly format the - version. +- **FIX**: Fix palette update logic that would not properly format the + version. ## 4.1.0 -- **NEW**: Add minimal color support in Sublime's built-in GraphViz - syntax files. Colors are currently limited to hex RGB/RGBA and color - names outside of HTML and full CSS support inside HTML. Support is - experimental, and if false positives are a problem, the rule can be - disabled in the settings. -- **NEW**: Don't default `tmtheme` custom class output to X11 names, - default to hex codes instead. -- **FIX**: Fix some additional custom class issues related to latest - `coloraide` update. +- **NEW**: Add minimal color support in Sublime's built-in GraphViz + syntax files. Colors are currently limited to hex RGB/RGBA and color + names outside of HTML and full CSS support inside HTML. Support is + experimental, and if false positives are a problem, the rule can be + disabled in the settings. +- **NEW**: Don't default `tmtheme` custom class output to X11 names, + default to hex codes instead. +- **FIX**: Fix some additional custom class issues related to latest + `coloraide` update. ## 4.0.1 -- **FIX**: Fix built-in custom color class match return. This caused files - using one of the built-in color classes to fail in creating previews. +- **FIX**: Fix built-in custom color class match return. This caused files + using one of the built-in color classes to fail in creating previews. ## 4.0.0 @@ -174,502 +176,502 @@ > If you have defined custom colors rules and specifically reference `xyz` > rules should be updated to refer to `xyz` as `xyz-d65`. -- **NEW**: Update to latest `coloraide` which provides minor bug fixes. - As the new version now includes type annotations, ColorHelper now - requires the `typing` dependency until it can be migrated to use Python - 3.8. Typing refactor did moderately affect custom color classes. -- **NEW**: `xyz` is now known as `xyz-d65` in the settings file. - If you have custom rules that override or add `xyz`, please update - the rules to reference `xyz-d65` instead. -- **NEW**: Gamut mapping now uses Oklch instead of CIE LCH per CSS recnet - specifications changes to the CSS Level 4 specification. -- **NEW**: Expose sRGB Linear color space per the CSS specification. -- **FIX**: Fix `blend` and `blenda` regression in emulation of Sublime's - ColorMod implementation. -- **FIX**: ColorPicker should not show colors maps with opacity in the - color map square. +- **NEW**: Update to latest `coloraide` which provides minor bug fixes. + As the new version now includes type annotations, ColorHelper now + requires the `typing` dependency until it can be migrated to use Python + 3.8. Typing refactor did moderately affect custom color classes. +- **NEW**: `xyz` is now known as `xyz-d65` in the settings file. + If you have custom rules that override or add `xyz`, please update + the rules to reference `xyz-d65` instead. +- **NEW**: Gamut mapping now uses Oklch instead of CIE LCH per CSS recnet + specifications changes to the CSS Level 4 specification. +- **NEW**: Expose sRGB Linear color space per the CSS specification. +- **FIX**: Fix `blend` and `blenda` regression in emulation of Sublime's + ColorMod implementation. +- **FIX**: ColorPicker should not show colors maps with opacity in the + color map square. ## 3.8.0 -- **NEW**: Allow selecting the preview gamut to control what RGB space - images previews are rendered in. For example, before this change, - macOS computers with Display P3 monitors would render sRGB colors - as Display P3 colors and could provide inaccurate previews. Now - you can set `gamut_space` to `display-p3` and sRGB and Display P3 - colors will be closer to their actual color. Gamut can be set to - `srgb`, `display-p3`, `a98-rgb`, `prophoto-rgb`, and `rec2020`. - Colors will on only make sense on displays of these types with - the appropriate color profile enabled. Directly related to - https://github.com/sublimehq/sublime_text/issues/4930. +- **NEW**: Allow selecting the preview gamut to control what RGB space + images previews are rendered in. For example, before this change, + macOS computers with Display P3 monitors would render sRGB colors + as Display P3 colors and could provide inaccurate previews. Now + you can set `gamut_space` to `display-p3` and sRGB and Display P3 + colors will be closer to their actual color. Gamut can be set to + `srgb`, `display-p3`, `a98-rgb`, `prophoto-rgb`, and `rec2020`. + Colors will on only make sense on displays of these types with + the appropriate color profile enabled. Directly related to + https://github.com/sublimehq/sublime_text/issues/4930. ## 3.7.0 -- **NEW**: Color contrast tool will now take any color space, even non-sRGB, - but the tool will only operate in the sRGB gamut as the compositing of - transparent colors defaults to sRGB and the contrast targeting algorithm - is currently done in the sRGB gamut using HWB. It will more clearly show - that the color has been gamut mapped in the results as it will now show - the modified color at all times. -- **NEW**: Upgrade `coloraide` which brings the possibility of using CIELuv, - LCH~uv~, DIN99o, DIN99o LCH, Okhsl, and Okhsv. Small improvements and fixes - also included. -- **NEW**: `color(xyz x y z)` now references D65 XYZ per latest CSS - specifications. `color(xyz-d50 x y z)` is now the old D50 XYZ variant. - `color(xyz-d65 x y z)` is also an alias for `color(xyz x y z)`. -- **NEW**: HSV and HSL store non-hue channels internally in the range of - 0 - 1 instead of 0 - 100. This affects the `color(space)` output form. -- **NEW**: Color Picker for HWB is not enabled by default anymore, but can - be enabled if desired via settings. -- **NEW**: ColorPicker improvements. Can now configure which color pickers - are enabled. Can specify a preferred color picker. Can specify whether - ColorHelper should take a color space and auto load the matching color - picker if it is enabled. Add new HSV, Okhsl, and Okhsv color pickers. -- **NEW**: New `coloraide` dependency may break custom color spaces not - provided with ColorHelper. If having issues, please open an issue to - get help. It is doubtful that many have delved too deeply in this area. -- **FIX**: Fix typos and wording in various color tool dialogs. -- **FIX**: Better behavior of color picker's handling of color. -- **FIX**: Fix issues with [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)) - support. -- **FIX**: Remove unnecessary dependencies. +- **NEW**: Color contrast tool will now take any color space, even non-sRGB, + but the tool will only operate in the sRGB gamut as the compositing of + transparent colors defaults to sRGB and the contrast targeting algorithm + is currently done in the sRGB gamut using HWB. It will more clearly show + that the color has been gamut mapped in the results as it will now show + the modified color at all times. +- **NEW**: Upgrade `coloraide` which brings the possibility of using CIELuv, + LCH~uv~, DIN99o, DIN99o LCH, Okhsl, and Okhsv. Small improvements and fixes + also included. +- **NEW**: `color(xyz x y z)` now references D65 XYZ per latest CSS + specifications. `color(xyz-d50 x y z)` is now the old D50 XYZ variant. + `color(xyz-d65 x y z)` is also an alias for `color(xyz x y z)`. +- **NEW**: HSV and HSL store non-hue channels internally in the range of + 0 - 1 instead of 0 - 100. This affects the `color(space)` output form. +- **NEW**: Color Picker for HWB is not enabled by default anymore, but can + be enabled if desired via settings. +- **NEW**: ColorPicker improvements. Can now configure which color pickers + are enabled. Can specify a preferred color picker. Can specify whether + ColorHelper should take a color space and auto load the matching color + picker if it is enabled. Add new HSV, Okhsl, and Okhsv color pickers. +- **NEW**: New `coloraide` dependency may break custom color spaces not + provided with ColorHelper. If having issues, please open an issue to + get help. It is doubtful that many have delved too deeply in this area. +- **FIX**: Fix typos and wording in various color tool dialogs. +- **FIX**: Better behavior of color picker's handling of color. +- **FIX**: Fix issues with [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)) + support. +- **FIX**: Remove unnecessary dependencies. ## 3.6.0 -- **NEW**: Add support for [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)). +- **NEW**: Add support for [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)). ## 3.5.0 -- **NEW**: `generic` rule will now allow scanning in strings by default. If this - is not desired, simply modify it in user settings to reflect desired behavior. -- **NEW**: Remove default palette file as it just contained examples that most - people would never use. -- **NEW**: Color palettes now provide a format version so that they can be upgraded - if needed. Due to the compatibility issue with a change for `color()` format, - color palettes will be upgraded. -- **FIX**: `color()` format for `lab` and other colors that have percentage only - channels must require those channels to be input as percentages per the CSS - level 4 specifications. This affects the string output for the `color()` format - as well. -- **FIX**: Latest `coloraide` improves gamut mapping. -- **FIX**: Small gamut fitting adjustments. -- **FIX**: Fix issue with duplicate previews when working with clone views. +- **NEW**: `generic` rule will now allow scanning in strings by default. If this + is not desired, simply modify it in user settings to reflect desired behavior. +- **NEW**: Remove default palette file as it just contained examples that most + people would never use. +- **NEW**: Color palettes now provide a format version so that they can be upgraded + if needed. Due to the compatibility issue with a change for `color()` format, + color palettes will be upgraded. +- **FIX**: `color()` format for `lab` and other colors that have percentage only + channels must require those channels to be input as percentages per the CSS + level 4 specifications. This affects the string output for the `color()` format + as well. +- **FIX**: Latest `coloraide` improves gamut mapping. +- **FIX**: Small gamut fitting adjustments. +- **FIX**: Fix issue with duplicate previews when working with clone views. ## 3.4.0 -- **NEW**: New color difference tool. -- **NEW**: New blend modes tool. -- **NEW**: Fix typo. `0xahex` color class should have been named `0xhex` in the - settings. -- **NEW**: New `coloraide` brings support for `oklab`, `oklch`, `jzazbz`, `jzczhz`, - `ICtCp`, D65 variations of CIELAB, CIELCH, and XYZ (none of which are enabled - as output options by default). -- **NEW**: Some refactoring of `coloraide` caused custom color classes to get - updated. User created custom classes may have to get updated to work. -- **FIX**: Upgrade `coloraide` which fixes issues related to inconsistent use of - D65 white values in XYZ transforms and Bradford CAT and other lesser bug fixes - as well. This particularly improves conversions to and from CIELAB. +- **NEW**: New color difference tool. +- **NEW**: New blend modes tool. +- **NEW**: Fix typo. `0xahex` color class should have been named `0xhex` in the + settings. +- **NEW**: New `coloraide` brings support for `oklab`, `oklch`, `jzazbz`, `jzczhz`, + `ICtCp`, D65 variations of CIELAB, CIELCH, and XYZ (none of which are enabled + as output options by default). +- **NEW**: Some refactoring of `coloraide` caused custom color classes to get + updated. User created custom classes may have to get updated to work. +- **FIX**: Upgrade `coloraide` which fixes issues related to inconsistent use of + D65 white values in XYZ transforms and Bradford CAT and other lesser bug fixes + as well. This particularly improves conversions to and from CIELAB. ## 3.3.1 -- **FIX**: Ensure that contrast related functions are using XYZ with D65 white - point instead of D50 in order to match WCAG 2.1 specifications. -- **FIX**: Fix some string output issues. -- **FIX**: Fix some algorithmic issues with Delta E 2000 which affects gamut - mapping. +- **FIX**: Ensure that contrast related functions are using XYZ with D65 white + point instead of D50 in order to match WCAG 2.1 specifications. +- **FIX**: Fix some string output issues. +- **FIX**: Fix some algorithmic issues with Delta E 2000 which affects gamut + mapping. ## 3.3.0 -- **NEW**: `preview_on_select` now supports multi-select. -- **NEW**: Color picker channels only show 10 steps back or forward at a given - time instead of 12 and are always perfectly scaled between 0% - 100%. -- **NEW**: Color box now shows hue on x-axis and saturation on y-axis. It also - replaces the gray scale bar with a lightness bar. -- **NEW**: Color picker now is more compact and hides the sliders unless the - user switches to slider mode. In that case, the color box will be hidden. -- **NEW**: Only the color picker's alpha channel will show a representation of - transparency. This allows the user to clearly see the color when adjusting - other channels. -- **NEW**: Color box in the color picker now shows an approximate indicator of - where the current color falls on the color box. -- **NEW**: Add indication of which button is selected in the color picker. -- **NEW**: Vendor `coloraide` as ColorHelper is tightly coupled to it. Vendoring - will ensure a better upgrade process. The default color classe is now referenced - via `ColorHelper.lib.coloraide` opposed to the old `coloraide`. -- **FIX**: Fix issues related to detecting when colors are in the visible viewport. -- **FIX**: Ensure that when a native color picker is called with no color, that if - the default color is picked, it will insert instead of ignore. +- **NEW**: `preview_on_select` now supports multi-select. +- **NEW**: Color picker channels only show 10 steps back or forward at a given + time instead of 12 and are always perfectly scaled between 0% - 100%. +- **NEW**: Color box now shows hue on x-axis and saturation on y-axis. It also + replaces the gray scale bar with a lightness bar. +- **NEW**: Color picker now is more compact and hides the sliders unless the + user switches to slider mode. In that case, the color box will be hidden. +- **NEW**: Only the color picker's alpha channel will show a representation of + transparency. This allows the user to clearly see the color when adjusting + other channels. +- **NEW**: Color box in the color picker now shows an approximate indicator of + where the current color falls on the color box. +- **NEW**: Add indication of which button is selected in the color picker. +- **NEW**: Vendor `coloraide` as ColorHelper is tightly coupled to it. Vendoring + will ensure a better upgrade process. The default color classe is now referenced + via `ColorHelper.lib.coloraide` opposed to the old `coloraide`. +- **FIX**: Fix issues related to detecting when colors are in the visible viewport. +- **FIX**: Ensure that when a native color picker is called with no color, that if + the default color is picked, it will insert instead of ignore. ## 3.2.2 -- **FIX**: Increase precision of palettes to properly match and store any colors. +- **FIX**: Increase precision of palettes to properly match and store any colors. ## 3.2.1 -- **FIX**: Ensure adding a color to a palette isn't shown when deleting palettes. +- **FIX**: Ensure adding a color to a palette isn't shown when deleting palettes. ## 3.2.0 -- **NEW**: Convert popup now lets you copy a color or insert a color. -- **NEW**: More tweaks to popup styles. -- **NEW**: Palette features, such as inserting a color from a palette and saving - a color to a palette, are all available under the `palette` menu option from - the main toolip. This consolidates options and makes the panel a bit more compact. -- **NEW**: Show current channel value in color picker's high resolution selector. -- **FIX**: Make "Out of gamut" tooltip more clear that it is referring to the preview - gamut. -- **FIX**: Palettes colors were inconsistently saved and compared. This caused colors - that were saved to "favorites" to sometimes not appear saved. -- **FIX**: Adjust scaling of images in regards to the `graphic_size` option. Medium - should be a scale of 1, small a scale of 0.75, large a scale of 1.25. For greater - control, use `graphic_scale`. -- **FIX**: Windows color picker should use `ctypes.pointer` not `ctypes.byref`. Fixes - Windows color picker not working on ST4. +- **NEW**: Convert popup now lets you copy a color or insert a color. +- **NEW**: More tweaks to popup styles. +- **NEW**: Palette features, such as inserting a color from a palette and saving + a color to a palette, are all available under the `palette` menu option from + the main toolip. This consolidates options and makes the panel a bit more compact. +- **NEW**: Show current channel value in color picker's high resolution selector. +- **FIX**: Make "Out of gamut" tooltip more clear that it is referring to the preview + gamut. +- **FIX**: Palettes colors were inconsistently saved and compared. This caused colors + that were saved to "favorites" to sometimes not appear saved. +- **FIX**: Adjust scaling of images in regards to the `graphic_size` option. Medium + should be a scale of 1, small a scale of 0.75, large a scale of 1.25. For greater + control, use `graphic_scale`. +- **FIX**: Windows color picker should use `ctypes.pointer` not `ctypes.byref`. Fixes + Windows color picker not working on ST4. ## 3.1.4 -- **FIX**: Fix `tmTheme` handling of compressed hex colors. +- **FIX**: Fix `tmTheme` handling of compressed hex colors. ## 3.1.3 -- **FIX**: A few fixes to new style. +- **FIX**: A few fixes to new style. ## 3.1.2 -- **FIX**: Improved visuals for popups and `TextInputHandlers`. Improve consistency - in how things are presented. -- **FIX**: In color picker, values in the hue channel should clamp at 359 not 360 as - 360 wraps to 0. -- **FIX**: Fix a few transitions between different popups. +- **FIX**: Improved visuals for popups and `TextInputHandlers`. Improve consistency + in how things are presented. +- **FIX**: In color picker, values in the hue channel should clamp at 359 not 360 as + 360 wraps to 0. +- **FIX**: Fix a few transitions between different popups. ## 3.1.1 -- **FIX**: Fix Windows color picker not processing color properly. -- **FIX**: Make sure Windows color picker stores and retrieves custom colors. +- **FIX**: Fix Windows color picker not processing color properly. +- **FIX**: Make sure Windows color picker stores and retrieves custom colors. ## 3.1.0 -- **NEW**: Drop support for ColorPicker package and instead implement OS color pickers - directly in ColorHelper. Linux users will need to install [`kcolorchooser`](https://apps.kde.org/en/kcolorchooser), - and it must be in the path on the command line +- **NEW**: Drop support for ColorPicker package and instead implement OS color pickers + directly in ColorHelper. Linux users will need to install [`kcolorchooser`](https://apps.kde.org/en/kcolorchooser), + and it must be in the path on the command line ## 3.0.2 -- **FIX**: Fix typo in color trigger pattern in 3.0.1 fix. +- **FIX**: Fix typo in color trigger pattern in 3.0.1 fix. ## 3.0.1 -- **FIX**: Ignore color keywords when they are preceded by `$` (SCSS). Also fix issue - with `-` trailing a keyword. +- **FIX**: Ignore color keywords when they are preceded by `$` (SCSS). Also fix issue + with `-` trailing a keyword. ## 3.0.0 -- **NEW**: New supported color spaces: `lch`, `lab`, `display-p3`, `rec-2020`, `xyz`, - `prophoto-rgb`, `a98-rgb`, and `hsv`. -- **NEW**: `rgb`, `hsl`, and `hwb` all support the new CSS format `rgb(r g b / a)`. -- **NEW**: `gray()` dropped as it is no longer part of the CSS level 4 specifications. -- **NEW**: All instances of `blacklist` and `whitelist` are now known as `blocklist` - and `allowlist` respectively. -- **NEW**: Outputs, when inserting or converting, can be controlled in settings file. -- **NEW**: Color triggers (what ColorHelper searches for before testing if the text - is a color) can be configured in settings file. This can allow a user to not trigger - on certain formats. -- **NEW**: If desired, users can provided a custom color class object to use for certain - files that can augment one or more supported color space's accepted input and output - formats. -- **NEW**: Improvements to scanning. Scanning will only occur in the viewable viewport. - Text that is not visible, both vertically or horizontally, will not be scanned until - it scrolls into view. -- **NEW**: New option `preview_on_select` to show color previews only when the cursor is - on the color or selecting the color (currently only applied to one selection). -- **NEW**: New color edit tool which allows a user to get a live update of the color as - they alter the coordinates, and allows the user to mix it with one other color. The - result can be inserted back into the file, or will be handed back to the color picker - if called from there. -- **NEW**: New color contrast tool which allows a user to see the contrast ratio and - see a visual representation of how the two colors contrast. The resulting foreground - color can be inserted back into the file, or will be handed back to the color picker - if called from there. -- **NEW**: New Sublime ColorMod tool which allows a user to see a `color-mod` expression - update a live color preview on the fly. -- **NEW**: Only one color rule (defined in the settings file) will apply to a given view. -- **NEW**: Renamed `color_scan` option to `color_rule`. -- **NEW**: Massive overhaul of color scanning and color scanning options. -- **NEW**: Colors that are out of gamut will be gamut mapped. On hover of the preivew - (on ST4), it will indicate that it has been gamut mapped. This can be changed via - `show_out_of_gamut_preview`, and additionally a fully transparent color swatch with a - "red-ish" border will be shown (color may vary based on color scheme). On mouse over, - it will also indicate that it is out of gamut on ST4. -- **NEW**: ColorHelper will now gamut map colors in some scenarios, either due to - necessity, or by user setting. -- **NEW**: New `generic` option is defined which provides a default input and output for - files with no rules. Users can use the color picker, and other color tools, from any - file now. Scanning is disabled by default and can be enabled if desired. `generic` can - be tweaked to provide whatever fallback experience the user desired. -- **NEW**: New command added to force scanning in a file that may have scanning disabled. - Also can force a file with scanning enabled to be disabled. -- **NEW**: Color helper will now recognize `transparent`. -- **NEW**: Color picker rainbow box will adjust based on the saturation of the current - selected color. -- **NEW**: Color picker will give a clear indication when you are at the end of a color - channel by showing no more boxes. -- **NEW**: Provide `user_color_rules` where a user can append rules without overwriting the - entire rule set. If a rule uses the same `name` as one of the existing default rules, - a shallow merge will be done so the values of the top level keys will be overridden - with the user keys and/or any additional keys will be added. -- **REMOVED**: Color completion. It mainly got in the way. The palette can be called any - time the user needs it. -- **REMOVED**: Hex shaped color picker option has been removed. -- **REMOVED**: Removed "current file palette". ColorHelper will no longer scan the entire - current file and generate a palette. This only worked in a limited number of files and - added much more complexity. -- **REMOVED**: Various options from rules sets. These are now controlled by the color - class object that is being used. For instance, input and output format of colors in the - form `#AARRGGBB` instead of the default `#RRGGBBAA` would need to use the new example - `ColorHelper.custom.ahex.ColorAhex` custom color object to read in and output hex colors - with leading alpha channels. -- **FIX**: Insert logic issues. -- **FIX**: ColorPicker now will always work in the color space of the current mode. This - fixes some conversion issues. +- **NEW**: New supported color spaces: `lch`, `lab`, `display-p3`, `rec-2020`, `xyz`, + `prophoto-rgb`, `a98-rgb`, and `hsv`. +- **NEW**: `rgb`, `hsl`, and `hwb` all support the new CSS format `rgb(r g b / a)`. +- **NEW**: `gray()` dropped as it is no longer part of the CSS level 4 specifications. +- **NEW**: All instances of `blacklist` and `whitelist` are now known as `blocklist` + and `allowlist` respectively. +- **NEW**: Outputs, when inserting or converting, can be controlled in settings file. +- **NEW**: Color triggers (what ColorHelper searches for before testing if the text + is a color) can be configured in settings file. This can allow a user to not trigger + on certain formats. +- **NEW**: If desired, users can provided a custom color class object to use for certain + files that can augment one or more supported color space's accepted input and output + formats. +- **NEW**: Improvements to scanning. Scanning will only occur in the viewable viewport. + Text that is not visible, both vertically or horizontally, will not be scanned until + it scrolls into view. +- **NEW**: New option `preview_on_select` to show color previews only when the cursor is + on the color or selecting the color (currently only applied to one selection). +- **NEW**: New color edit tool which allows a user to get a live update of the color as + they alter the coordinates, and allows the user to mix it with one other color. The + result can be inserted back into the file, or will be handed back to the color picker + if called from there. +- **NEW**: New color contrast tool which allows a user to see the contrast ratio and + see a visual representation of how the two colors contrast. The resulting foreground + color can be inserted back into the file, or will be handed back to the color picker + if called from there. +- **NEW**: New Sublime ColorMod tool which allows a user to see a `color-mod` expression + update a live color preview on the fly. +- **NEW**: Only one color rule (defined in the settings file) will apply to a given view. +- **NEW**: Renamed `color_scan` option to `color_rule`. +- **NEW**: Massive overhaul of color scanning and color scanning options. +- **NEW**: Colors that are out of gamut will be gamut mapped. On hover of the preivew + (on ST4), it will indicate that it has been gamut mapped. This can be changed via + `show_out_of_gamut_preview`, and additionally a fully transparent color swatch with a + "red-ish" border will be shown (color may vary based on color scheme). On mouse over, + it will also indicate that it is out of gamut on ST4. +- **NEW**: ColorHelper will now gamut map colors in some scenarios, either due to + necessity, or by user setting. +- **NEW**: New `generic` option is defined which provides a default input and output for + files with no rules. Users can use the color picker, and other color tools, from any + file now. Scanning is disabled by default and can be enabled if desired. `generic` can + be tweaked to provide whatever fallback experience the user desired. +- **NEW**: New command added to force scanning in a file that may have scanning disabled. + Also can force a file with scanning enabled to be disabled. +- **NEW**: Color helper will now recognize `transparent`. +- **NEW**: Color picker rainbow box will adjust based on the saturation of the current + selected color. +- **NEW**: Color picker will give a clear indication when you are at the end of a color + channel by showing no more boxes. +- **NEW**: Provide `user_color_rules` where a user can append rules without overwriting the + entire rule set. If a rule uses the same `name` as one of the existing default rules, + a shallow merge will be done so the values of the top level keys will be overridden + with the user keys and/or any additional keys will be added. +- **REMOVED**: Color completion. It mainly got in the way. The palette can be called any + time the user needs it. +- **REMOVED**: Hex shaped color picker option has been removed. +- **REMOVED**: Removed "current file palette". ColorHelper will no longer scan the entire + current file and generate a palette. This only worked in a limited number of files and + added much more complexity. +- **REMOVED**: Various options from rules sets. These are now controlled by the color + class object that is being used. For instance, input and output format of colors in the + form `#AARRGGBB` instead of the default `#RRGGBBAA` would need to use the new example + `ColorHelper.custom.ahex.ColorAhex` custom color object to read in and output hex colors + with leading alpha channels. +- **FIX**: Insert logic issues. +- **FIX**: ColorPicker now will always work in the color space of the current mode. This + fixes some conversion issues. ## 2.7.1 -- **FIX**: Fix issues with 2.7.X release and latest mdpopups. +- **FIX**: Fix issues with 2.7.X release and latest mdpopups. ## 2.7.0 -- **NEW**: HSL can support alpha channels as `hsl` or `hsla` (per the CSS spec). -- **NEW**: HSL and HWB now support units `deg`, `turn`, `rad`, `grad` for the hue channel. +- **NEW**: HSL can support alpha channels as `hsl` or `hsla` (per the CSS spec). +- **NEW**: HSL and HWB now support units `deg`, `turn`, `rad`, `grad` for the hue channel. ## 2.6.0 -- **NEW**: Enable CSS level 4 colors by default. -- **NEW**: Reduce borders and in some cases remove borders around color previews. -- **NEW**: Allow insertion of colors when there is an active selection. -- **NEW**: Add option to override the border color used around color previews in -the popup. -- **FIX**: Reduce busy processes when idle. -- **FIX**: Fixes for SCSS and Sass packages. -- **FIX**: Fix issue where inline color phantom could make the line height larger. -- **FIX**: Fix compressed hex with alpha handling. -- **FIX**: Fix `hwb` display in info dialog. -- **FIX**: Project palettes not showing up. -- **FIX**: Fix for compressed hex colors with alpha. -- **FIX**: Ensure minimum size of graphics in order to prevent issue where an -error is thrown when image size is too small. +- **NEW**: Enable CSS level 4 colors by default. +- **NEW**: Reduce borders and in some cases remove borders around color previews. +- **NEW**: Allow insertion of colors when there is an active selection. +- **NEW**: Add option to override the border color used around color previews in + the popup. +- **FIX**: Reduce busy processes when idle. +- **FIX**: Fixes for SCSS and Sass packages. +- **FIX**: Fix issue where inline color phantom could make the line height larger. +- **FIX**: Fix compressed hex with alpha handling. +- **FIX**: Fix `hwb` display in info dialog. +- **FIX**: Project palettes not showing up. +- **FIX**: Fix for compressed hex colors with alpha. +- **FIX**: Ensure minimum size of graphics in order to prevent issue where an + error is thrown when image size is too small. ## 2.5.1 -- **FIX**: Flicker of colors due to overly aggressive color preview deletion. -- **FIX**: Update to latest `rgba` library. +- **FIX**: Flicker of colors due to overly aggressive color preview deletion. +- **FIX**: Update to latest `rgba` library. ## 2.5.0 -- **NEW**: Use the newer API for opening settings [#78](https://github.com/facelessuser/ColorHelper/pull/78). -- **NEW**: Require ST 3124+ (this will also be limited in Package Control soonish). -- **NEW**: Add basic support for Less [#92](https://github.com/facelessuser/ColorHelper/pull/92). -- **FIX**: Small fonts are not as small now. +- **NEW**: Use the newer API for opening settings [#78](https://github.com/facelessuser/ColorHelper/pull/78). +- **NEW**: Require ST 3124+ (this will also be limited in Package Control soonish). +- **NEW**: Add basic support for Less [#92](https://github.com/facelessuser/ColorHelper/pull/92). +- **FIX**: Small fonts are not as small now. ## 2.4.2 -- **FIX**: Fix HTML escape of palette names. [#84](https://github.com/facelessuser/ColorHelper/issues/84) -- **FIX**: Fix preview clicking. [#81](https://github.com/facelessuser/ColorHelper/issues/81) -- **FIX**: Fix margins on previews. [#83](https://github.com/facelessuser/ColorHelper/issues/83) +- **FIX**: Fix HTML escape of palette names. [#84](https://github.com/facelessuser/ColorHelper/issues/84) +- **FIX**: Fix preview clicking. [#81](https://github.com/facelessuser/ColorHelper/issues/81) +- **FIX**: Fix margins on previews. [#83](https://github.com/facelessuser/ColorHelper/issues/83) ## 2.4.1 -- **FIX**: Speed improvements for rendering previews. -- **FIX**: More fixes for duplicate preview prevention. +- **FIX**: Speed improvements for rendering previews. +- **FIX**: More fixes for duplicate preview prevention. ## 2.4.0 -- **NEW**: More subtle preview borders. [7e983cd](https://github.com/facelessuser/ColorHelper/commit/7e983cda9682648eb86fc556b65578f6319f7661) -- **FIX**: Setting race condition. [2336ee5](https://github.com/facelessuser/ColorHelper/commit/2336ee554fb6add79ccd1a0ad1ac15d3c4576e39) -- **FIX**: Fix preview tagging. [#72](https://github.com/facelessuser/ColorHelper/issues/72) -- **FIX**: Fix CSS3 support. [#73](https://github.com/facelessuser/ColorHelper/issues/73) -- **FIX**: Consistent handling hex casing. [#74](https://github.com/facelessuser/ColorHelper/issues/74) +- **NEW**: More subtle preview borders. [7e983cd](https://github.com/facelessuser/ColorHelper/commit/7e983cda9682648eb86fc556b65578f6319f7661) +- **FIX**: Setting race condition. [2336ee5](https://github.com/facelessuser/ColorHelper/commit/2336ee554fb6add79ccd1a0ad1ac15d3c4576e39) +- **FIX**: Fix preview tagging. [#72](https://github.com/facelessuser/ColorHelper/issues/72) +- **FIX**: Fix CSS3 support. [#73](https://github.com/facelessuser/ColorHelper/issues/73) +- **FIX**: Consistent handling hex casing. [#74](https://github.com/facelessuser/ColorHelper/issues/74) ## 2.3.0 -- **NEW**: New quickstart command in menu. -- **NEW**: Links in menu to navigate to official documentation and issue tracker. -- **FIX**: Fix for Sass. [#68](https://github.com/facelessuser/ColorHelper/issues/68) +- **NEW**: New quickstart command in menu. +- **NEW**: Links in menu to navigate to official documentation and issue tracker. +- **FIX**: Fix for Sass. [#68](https://github.com/facelessuser/ColorHelper/issues/68) ## 2.2.0 -- **New**: Add support for stTheme and search cdata [#59](https://github.com/facelessuser/ColorHelper/pull/59). -- **NEW**: Workaround for Windows 10 HiDpi large image issue [#61](https://github.com/facelessuser/ColorHelper/issues/61). -See [document](http://facelessuser.github.io/ColorHelper/usage/#line_height_workaround) for more info. -- **NEW**: Added toggle support for left/right positioned previews [#65](https://github.com/facelessuser/ColorHelper/pull/65). -See [document](http://facelessuser.github.io/ColorHelper/usage/#inline_preview_position) for more info. -- **FIX**: Web Color insertion bug [#62](https://github.com/facelessuser/ColorHelper/issues/63). -- **FIX**: Preview duplication bug (hopefully -- please report if not fixed) [#57](https://github.com/facelessuser/ColorHelper/issues/57). +- **New**: Add support for stTheme and search cdata [#59](https://github.com/facelessuser/ColorHelper/pull/59). +- **NEW**: Workaround for Windows 10 HiDpi large image issue [#61](https://github.com/facelessuser/ColorHelper/issues/61). + See [document](http://facelessuser.github.io/ColorHelper/usage/#line_height_workaround) for more info. +- **NEW**: Added toggle support for left/right positioned previews [#65](https://github.com/facelessuser/ColorHelper/pull/65). + See [document](http://facelessuser.github.io/ColorHelper/usage/#inline_preview_position) for more info. +- **FIX**: Web Color insertion bug [#62](https://github.com/facelessuser/ColorHelper/issues/63). +- **FIX**: Preview duplication bug (hopefully -- please report if not fixed) [#57](https://github.com/facelessuser/ColorHelper/issues/57). ## 2.1.1 -- **FIX**: CSS tweaks (minihtml) -- **FIX**: Support for CSS3 package +- **FIX**: CSS tweaks (minihtml) +- **FIX**: Support for CSS3 package ## 2.1.0 -- **NEW**: Moved popup panel formatting into external template files. Requires -mdpopups 1.9.3. -- **FIX**: Fix issues where certain popups (colorpicker after manual color -edit) would get overridden by auto-popups of the color info panel. -- **FIX**: Issues related to inserted colors. -- **FIX**: Fixed issue where certain colors that required word boundaries where -still getting marked even though they were preceeded by invalid characters such -as `@#$.-_`. +- **NEW**: Moved popup panel formatting into external template files. Requires + mdpopups 1.9.3. +- **FIX**: Fix issues where certain popups (colorpicker after manual color + edit) would get overridden by auto-popups of the color info panel. +- **FIX**: Issues related to inserted colors. +- **FIX**: Fixed issue where certain colors that required word boundaries where + still getting marked even though they were preceeded by invalid characters such + as `@#$.-_`. ## 2.0.5 -- **FIX**: Fix changelog typo -- **FIX**: Fix odd behavior when checking padding +- **FIX**: Fix changelog typo +- **FIX**: Fix odd behavior when checking padding ## 2.0.4 -- **NEW**: Changelog command available in `Package Settings->ColorHelper`. -Will render a full changelog in an HTML phantom in a new view. -- **FIX**: Move colorbox before color I like this as now the colorbox resides -within the region of the color. And they will all line up even if color -format is different following them. (Fixes #46) -- **FIX**: Fix flicker on colorbox click. (Fixes #41) +- **NEW**: Changelog command available in `Package Settings->ColorHelper`. + Will render a full changelog in an HTML phantom in a new view. +- **FIX**: Move colorbox before color I like this as now the colorbox resides + within the region of the color. And they will all line up even if color + format is different following them. (Fixes #46) +- **FIX**: Fix flicker on colorbox click. (Fixes #41) ## 2.0.3 -- **FIX**: Don't allow previews to truncated colors. -- **FIX**: When validating existing phantoms, ensure the scopes still match -(like when code gets commented out etc.). -- **FIX**: Support new rem units if using mdpopups 1.8.0 for better font -scaling. +- **FIX**: Don't allow previews to truncated colors. +- **FIX**: When validating existing phantoms, ensure the scopes still match + (like when code gets commented out etc.). +- **FIX**: Support new rem units if using mdpopups 1.8.0 for better font + scaling. ## 2.0.2 -- **FIX**: Fix breakage for ST versions without phantoms. +- **FIX**: Fix breakage for ST versions without phantoms. ## 2.0.1 -- **FIX**: Less clearing of inline images. -- **FIX**: Per os/host setting for inline_preview_offset and graphic_size. -- **FIX**: Single border around preview that contrasts with the the theme +- **FIX**: Less clearing of inline images. +- **FIX**: Per os/host setting for inline_preview_offset and graphic_size. +- **FIX**: Single border around preview that contrasts with the the theme background. ## 2.0.0 -- **NEW**: Show inline color previews in Sublime Text 3118+! Can be turned off -if the feature is not desired. -- **NEW**: *Should* update mdpopups to the latest one on Package Control -upgrade (restart required after upgrade). Haven't actually confirmed if it -works. -- **FIX**: Images should scale with font size in Sublime Text 3118+. You can -still select small, medium, and large resources, but they will be relative to -the font size now. +- **NEW**: Show inline color previews in Sublime Text 3118+! Can be turned off + if the feature is not desired. +- **NEW**: *Should* update mdpopups to the latest one on Package Control + upgrade (restart required after upgrade). Haven't actually confirmed if it + works. +- **FIX**: Images should scale with font size in Sublime Text 3118+. You can + still select small, medium, and large resources, but they will be relative to + the font size now. ## 1.4.2 -- **FIX**: #39 Fix font size too small in popup. +- **FIX**: #39 Fix font size too small in popup. ## 1.4.1 -- **FIX**: Remove distortion workarounds as later Sublime versions no longer -distort images. -- **FIX**: Utilize latest mdpopups to handle font sizes. +- **FIX**: Remove distortion workarounds as later Sublime versions no longer + distort images. +- **FIX**: Utilize latest mdpopups to handle font sizes. ## 1.4.0 -- **NEW**: Allow disabling status message via the settings file option -`show_status_index`. -- **FIX**: Fix decimal level tracking when indexing. +- **NEW**: Allow disabling status message via the settings file option + `show_status_index`. +- **FIX**: Fix decimal level tracking when indexing. ## 1.3.5 -- **FIX**: Fixed issue where stored decimal size was faulty and could cause -the current file color indexing to fail. +- **FIX**: Fixed issue where stored decimal size was faulty and could cause + the current file color indexing to fail. ## 1.3.4 -- **FIX**: Fix logic for populating a view's ColorHelper specific settings on -activation and save. +- **FIX**: Fix logic for populating a view's ColorHelper specific settings on + activation and save. ## 1.3.3 -- **FIX**: Fixes related to gray, hsla, and hwba. +- **FIX**: Fixes related to gray, hsla, and hwba. ## 1.3.2 -- **FIX**: Fix version in message. +- **FIX**: Fix version in message. ## 1.3.1 -- **FIX** Forgot to strip extension on syntax compare. +- **FIX** Forgot to strip extension on syntax compare. ## 1.3.0 -- **NEW**: Color preview will now show transparent colors with and without -transparency. -- **NEW**: Transparent colors can now be stored and showed in palettes. -- **NEW**: Specifying files for scanning has been reworked and is now more -flexible. -- **NEW**: Color Helper no longer has preferred formats when inserting. It -will always prompt the user for their desired input format. -- **NEW**: Added CSS4's rebeccapurple to the webcolor names. -- **NEW**: Added better rgb and rgba support: percentage format, decimal -format (CSS4), percentage alpha (CSS4). -- **NEW**: Added support for alpha channel as percentage for hsla (CSS4). -- **NEW**: Support CSS4 gray, hwb, and hex values with alpha channels. -- **NEW**: Option to read hex with alpha channel as `#AARRGGBB` instead of -`#RRGGBBAA`. -- **NEW**: Option to compress hex values if possible on output: `#334455` --> -`#345`. -- **NEW**: Can disable auto-popups if desired. -- **NEW**: Insert options now are now more dynamic and only show valid options -for the current view. -- **FIX**: Clamp color channel values out of range. +- **NEW**: Color preview will now show transparent colors with and without + transparency. +- **NEW**: Transparent colors can now be stored and showed in palettes. +- **NEW**: Specifying files for scanning has been reworked and is now more + flexible. +- **NEW**: Color Helper no longer has preferred formats when inserting. It + will always prompt the user for their desired input format. +- **NEW**: Added CSS4's rebeccapurple to the webcolor names. +- **NEW**: Added better rgb and rgba support: percentage format, decimal + format (CSS4), percentage alpha (CSS4). +- **NEW**: Added support for alpha channel as percentage for hsla (CSS4). +- **NEW**: Support CSS4 gray, hwb, and hex values with alpha channels. +- **NEW**: Option to read hex with alpha channel as `#AARRGGBB` instead of + `#RRGGBBAA`. +- **NEW**: Option to compress hex values if possible on output: `#334455` --> + `#345`. +- **NEW**: Can disable auto-popups if desired. +- **NEW**: Insert options now are now more dynamic and only show valid options + for the current view. +- **FIX**: Clamp color channel values out of range. ## 1.2.1 -- **FIX**: Remove project commands that do nothing +- **FIX**: Remove project commands that do nothing ## 1.2.0 -- **NEW**: Color picker will appear in palette panel if no color info panel -is allowed. -- **NEW**: Added "supported_syntax_incomplete_only" for incomplete colors that -may not yet be scoped within a valid scope due to being incomplete. -- **NEW**: In the color picker, instead of the select link, you now must -choose the css format to insert via the corresponding `>>>` link. -- **NEW**: Add alternate rectangular color picker look. You can use the new -form by disabling the hex color picker look via the use_hex_color_picker -setting. -- **NEW**: Added new CSS Color Name picker (available in the color picker). -- **FIX**: When manually forcing the popup via the command palette, the -tooltip was getting closed. ColorHelper is now aware of manual and auto popup -tooltips and will only auto close the auto popups when ignored while typing. +- **NEW**: Color picker will appear in palette panel if no color info panel + is allowed. +- **NEW**: Added "supported_syntax_incomplete_only" for incomplete colors that + may not yet be scoped within a valid scope due to being incomplete. +- **NEW**: In the color picker, instead of the select link, you now must + choose the css format to insert via the corresponding `>>>` link. +- **NEW**: Add alternate rectangular color picker look. You can use the new + form by disabling the hex color picker look via the use_hex_color_picker + setting. +- **NEW**: Added new CSS Color Name picker (available in the color picker). +- **FIX**: When manually forcing the popup via the command palette, the + tooltip was getting closed. ColorHelper is now aware of manual and auto popup + tooltips and will only auto close the auto popups when ignored while typing. ## 1.1.0 -- **NEW**: Color picker built into the tooltips (optionally can be overridden -with ColorPicker Package's color picker). -- **NEW**: Graphic sizes are now configurable in settings. -- **NEW**: Color tooltip used to popup up when a user started to type a color, -but would stay open even when the user ignored it. Now it will auto close in -this scenario. -- **NEW**: Settings are accessible via `Preferences->Package Settings->ColorHelper` -in the menu. Support for the SCSS package added. +- **NEW**: Color picker built into the tooltips (optionally can be overridden + with ColorPicker Package's color picker). +- **NEW**: Graphic sizes are now configurable in settings. +- **NEW**: Color tooltip used to popup up when a user started to type a color, + but would stay open even when the user ignored it. Now it will auto close in + this scenario. +- **NEW**: Settings are accessible via `Preferences->Package Settings->ColorHelper` + in the menu. Support for the SCSS package added. ## 1.0.3 -- **FIX**: Use dependency that does not clash -- **FIX**: Add more scope support for POST CSS +- **FIX**: Use dependency that does not clash +- **FIX**: Add more scope support for POST CSS ## 1.0.2 -- **FIX**: Typo in code where view_window should have been view.window +- **FIX**: Typo in code where view_window should have been view.window -# ColorHlper 1.0.1 +# 1.0.1 -- **FIX**: Markdown dependency needs to not clash with default Markdown -package. Renamed to python-markdown. +- **FIX**: Markdown dependency needs to not clash with default Markdown + package. Renamed to python-markdown. ## 1.0.0 -- **NEW**: Initial release. +- **NEW**: Initial release. diff --git a/messages.json b/messages.json index b1abb29..377e3e7 100644 --- a/messages.json +++ b/messages.json @@ -1,4 +1,4 @@ { "install": "messages/install.md", - "6.3.0": "messages/recent.md" + "6.4.0": "messages/recent.md" } diff --git a/messages/recent.md b/messages/recent.md index 8c2cf19..5a07622 100644 --- a/messages/recent.md +++ b/messages/recent.md @@ -9,8 +9,11 @@ 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.3.0 - -- **NEW**: Upgrade to ColorAide 2.9. -- **FIX**: Fix some issues with blend and contrast tool. +- **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. diff --git a/support.py b/support.py index 29de5be..e8c6c6d 100644 --- a/support.py +++ b/support.py @@ -5,7 +5,7 @@ import webbrowser import re -__version__ = "6.3.2" +__version__ = "6.4.0" __pc_name__ = 'ColorHelper' CSS = ''' From bf6a2cad72ddefa7a73c5c49a5a5e173822a389d Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 20 May 2024 10:24:54 -0600 Subject: [PATCH 7/8] Add missing changelog note --- CHANGES.md | 1 + messages/recent.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index ae6d2eb..39f73eb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,7 @@ 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. ## 6.3.2 diff --git a/messages/recent.md b/messages/recent.md index 5a07622..a4566e8 100644 --- a/messages/recent.md +++ b/messages/recent.md @@ -17,3 +17,4 @@ related to the upgrade to stable `coloraide`. 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. From 5d44541cc18896336bd1801cc4997a5008e5d98d Mon Sep 17 00:00:00 2001 From: Isaac Muse Date: Tue, 21 May 2024 14:56:32 -0600 Subject: [PATCH 8/8] Fix issue with API update (#270) Fixes #269 --- CHANGES.md | 5 +++++ ch_picker.py | 6 +++--- messages/recent.md | 1 + support.py | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 39f73eb..3108388 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,10 @@ # ColorHelper +## 6.4.1 + +- **FIX**: Fix regression due to not accounting to API change when + upgrading to latest ColorAide. + ## 6.4.0 - **NEW**: Opt into Python 3.8. diff --git a/ch_picker.py b/ch_picker.py index a31f43b..2f65ff8 100644 --- a/ch_picker.py +++ b/ch_picker.py @@ -502,7 +502,7 @@ def get_channel(self, channel, label, color_filter, mode='undefined'): clone = self.color.clone() show_alpha = color_filter == 'alpha' - coord = alg.no_nan(clone[color_filter]) + coord = clone.get(color_filter, nans=False) if color_filter != 'hue': rounded = alg.round_half_up(coord, 2 if mode != 'hsluv' else 0) clone[color_filter] = rounded @@ -514,7 +514,7 @@ def get_channel(self, channel, label, color_filter, mode='undefined'): first = True while count: - coord = alg.no_nan(clone[color_filter]) - step + coord = clone.get(color_filter, nans=False) - step clone[color_filter] = coord if color_filter != "hue" and (coord < 0 or coord > (1 * scale)): @@ -564,7 +564,7 @@ def get_channel(self, channel, label, color_filter, mode='undefined'): clone.update(self.color) clone[color_filter] = rounded while count: - coord = alg.no_nan(clone[color_filter]) + step + coord = clone.get(color_filter, nans=False) + step clone[color_filter] = coord if color_filter != "hue" and (coord < 0 or coord > (1 * scale)): diff --git a/messages/recent.md b/messages/recent.md index a4566e8..c057d25 100644 --- a/messages/recent.md +++ b/messages/recent.md @@ -9,6 +9,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 - **NEW**: Opt into Python 3.8. diff --git a/support.py b/support.py index e8c6c6d..f589f54 100644 --- a/support.py +++ b/support.py @@ -5,7 +5,7 @@ import webbrowser import re -__version__ = "6.4.0" +__version__ = "6.4.1" __pc_name__ = 'ColorHelper' CSS = '''