From 712b261ef86a9e277c926bfcb2ac81363788b673 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 19 Feb 2022 07:57:11 -0700 Subject: [PATCH 1/4] Upgrade Coloraide --- CHANGES.md | 14 ++++ ch_util.py | 9 ++- color_helper.sublime-settings | 11 ++-- lib/coloraide/__meta__.py | 2 +- lib/coloraide/color.py | 22 +++---- lib/coloraide/gamut/__init__.py | 7 +- lib/coloraide/gamut/fit_lch_chroma.py | 1 + lib/coloraide/gamut/fit_oklch_chroma.py | 87 +++++++++++-------------- lib/coloraide/interpolate.py | 48 +++++++++----- lib/coloraide/spaces/__init__.py | 6 ++ lib/coloraide/spaces/srgb/__init__.py | 1 + lib/coloraide/util.py | 4 +- messages.json | 2 +- messages/recent.md | 26 ++++---- support.py | 2 +- 15 files changed, 136 insertions(+), 106 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6627f10b..6e387655 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,19 @@ # ColorHelper +## 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 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 diff --git a/ch_util.py b/ch_util.py index 49a56db7..b6191aac 100644 --- a/ch_util.py +++ b/ch_util.py @@ -22,8 +22,13 @@ COLOR_FMT_2_0 = (0, 3, 0, 'final') PALETTE_FMT = (2, 0) -RE_COLOR_START = \ - r"(?i)(?:\b(? Version: return Version(major, minor, micro, release, pre, post, dev) -__version_info__ = Version(0, 8, 0, "final") +__version_info__ = Version(0, 10, 0, "final") __version__ = __version_info__._get_canonical() diff --git a/lib/coloraide/color.py b/lib/coloraide/color.py index 8ec72651..1a2ca438 100644 --- a/lib/coloraide/color.py +++ b/lib/coloraide/color.py @@ -25,8 +25,8 @@ from .spaces.rec2020 import Rec2020 from .spaces.xyz_d65 import XYZD65 from .spaces.xyz_d50 import XYZD50 -from .spaces.oklab import Oklab -from .spaces.oklch import Oklch +from .spaces.oklab.css import Oklab +from .spaces.oklch.css import Oklch from .spaces.jzazbz import Jzazbz from .spaces.jzczhz import JzCzhz from .spaces.ictcp import ICtCp @@ -107,6 +107,7 @@ class Color(metaclass=BaseColor): FIT_MAP = {} # type: Dict[str, Type[Fit]] PRECISION = util.DEF_PREC FIT = util.DEF_FIT + INTERPOLATE = util.DEF_INTERPOLATE DELTA_E = util.DEF_DELTA_E CHROMATIC_ADAPTATION = 'bradford' @@ -375,11 +376,7 @@ def new( filters: Optional[Sequence[str]] = None, **kwargs: Any ) -> 'Color': - """ - Create new color object. - - TODO: maybe allow `currentcolor` here? It would basically clone the current object. - """ + """Create new color object.""" return type(self)(color, data, alpha, filters=filters, **kwargs) @@ -496,7 +493,7 @@ def clip(self, space: Optional[str] = None, *, in_place: bool = False) -> 'Color name = c._space.hue_name() c.set(name, util.constrain_hue(c.get(name))) else: - c._space._coords = gamut.clip_channels(c) + gamut.clip_channels(c) c.normalize() # Adjust "this" color @@ -542,7 +539,7 @@ def fit( c.set(name, util.constrain_hue(c.get(name))) else: # Doesn't seem to be an easy way that `mypy` can know whether this is the ABC class or not - c._space._coords = func(c, **kwargs) + func(c, **kwargs) c.normalize() # Adjust "this" color @@ -588,6 +585,7 @@ def steps( steps: int = 2, max_steps: int = 1000, max_delta_e: float = 0, + delta_e: Optional[str] = None, **interpolate_args: Any ) -> List['Color']: """ @@ -602,7 +600,7 @@ def steps( Default delta E method used is delta E 76. """ - return self.interpolate(color, **interpolate_args).steps(steps, max_steps, max_delta_e) + return self.interpolate(color, **interpolate_args).steps(steps, max_steps, max_delta_e, delta_e) def mix( self, @@ -628,7 +626,7 @@ def interpolate( self, color: Union[Union[ColorInput, interpolate.Piecewise], Sequence[Union[ColorInput, interpolate.Piecewise]]], *, - space: str = "lab", + space: Optional[str] = None, out_space: Optional[str] = None, stop: float = 0, progress: Optional[Callable[..., float]] = None, @@ -647,7 +645,7 @@ def interpolate( mixing occurs. """ - space = space.lower() + space = (space if space is not None else self.INTERPOLATE).lower() out_space = self.space() if out_space is None else out_space.lower() # A piecewise object was provided, so treat it as such, diff --git a/lib/coloraide/gamut/__init__.py b/lib/coloraide/gamut/__init__.py index a78a0a12..f63ccf4d 100644 --- a/lib/coloraide/gamut/__init__.py +++ b/lib/coloraide/gamut/__init__.py @@ -1,6 +1,5 @@ """Gamut handling.""" from .. import util -from ..util import MutableVector from ..spaces import FLG_ANGLE, GamutBound from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING, Optional, Any @@ -9,7 +8,7 @@ from ..color import Color -def clip_channels(color: 'Color') -> MutableVector: +def clip_channels(color: 'Color') -> None: """Clip channels.""" channels = util.no_nans(color.coords()) @@ -34,7 +33,7 @@ def clip_channels(color: 'Color') -> MutableVector: # Fit value in bounds. fit.append(util.clamp(value, a, b)) - return fit + color.update(color.space(), fit, color.alpha) def verify(color: 'Color', tolerance: float) -> bool: @@ -68,5 +67,5 @@ class Fit(ABCMeta): @classmethod @abstractmethod - def fit(cls, color: 'Color', **kwargs: Any) -> MutableVector: + def fit(cls, color: 'Color', **kwargs: Any) -> None: """Get coordinates of the new gamut mapped color.""" diff --git a/lib/coloraide/gamut/fit_lch_chroma.py b/lib/coloraide/gamut/fit_lch_chroma.py index f1546402..df47536f 100644 --- a/lib/coloraide/gamut/fit_lch_chroma.py +++ b/lib/coloraide/gamut/fit_lch_chroma.py @@ -12,3 +12,4 @@ class LchChroma(OklchChroma): DE = "2000" SPACE = "lch" SPACE_COORDINATE = "{}.chroma".format(SPACE) + MAX_LIGHTNESS = 100 diff --git a/lib/coloraide/gamut/fit_oklch_chroma.py b/lib/coloraide/gamut/fit_oklch_chroma.py index 757eca53..5d8bb057 100644 --- a/lib/coloraide/gamut/fit_oklch_chroma.py +++ b/lib/coloraide/gamut/fit_oklch_chroma.py @@ -1,6 +1,6 @@ """Fit by compressing chroma in Oklch.""" -from ..gamut import Fit -from ..util import MutableVector +from ..gamut import Fit, clip_channels +from ..util import NaN from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover @@ -17,67 +17,54 @@ class OklchChroma(Fit): DE = "ok" SPACE = "oklch" SPACE_COORDINATE = "{}.chroma".format(SPACE) + MIN_LIGHTNESS = 0 + MAX_LIGHTNESS = 1 @classmethod - def fit(cls, color: 'Color', **kwargs: Any) -> MutableVector: + def fit(cls, color: 'Color', **kwargs: Any) -> None: """ - Gamut mapping via Oklch chroma. - Algorithm originally came from https://colorjs.io/docs/gamut-mapping.html. - Some things have been optimized and fixed though to better perform as intended. - Algorithm is not also defined in the CSS specification: https://drafts.csswg.org/css-color/#binsearch. - - The idea is to hold hue and lightness constant and decrease chroma until - color comes under gamut. + Some things have been optimized and fixed though to better perform as intended. - We'll use a binary search and at after each stage, we will clip the color - and compare the distance of the two colors (clipped and current color via binary search). - If the distance is less than the `JND`, we will return the color. + Algorithm basically uses a combination of chroma compression which helps to keep hue constant + and color distancing to clip the color with minimal changes to other channels. - The basic idea is preserve lightness and hue as much as possible and find the closest color that - is in gamut. + We do not use the algorithm as defined in the CSS specification: https://drafts.csswg.org/css-color/#binsearch. + This is because the current algorithm, as defined, has some issues that create gradients that are not smooth. --- Original Authors: Lea Verou, Chris Lilley - License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json) + License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json). """ - space = color.space() - - # If flooring chroma doesn't work, just clip the floored color - # because there is no optimal compression. - floor = color.clone().set(cls.SPACE_COORDINATE, 0) - if not floor.in_gamut(tolerance=0): - return floor.clip().coords() - - # If we are already below the JND, just clip as we will gain no - # noticeable difference moving forward. - clipped = color.clip() - if color.delta_e(clipped, method=cls.DE) < cls.LIMIT: - return clipped.coords() - # Convert to CIELCH and set our boundaries mapcolor = color.convert(cls.SPACE) - low = 0.0 - high = mapcolor.chroma - - # Adjust chroma (using binary search). - # This helps preserve the other attributes of the color. - # Each time we compare the compressed color to it's clipped form - # to see how close we are. A delta less than 2 is our target. - while (high - low) > cls.EPSILON: - delta = mapcolor.delta_e( - mapcolor.clip(space), - method=cls.DE - ) - if (delta - cls.LIMIT) < cls.EPSILON: - low = mapcolor.chroma - else: - high = mapcolor.chroma + # Return white or black if lightness is out of range + lightness = mapcolor.lightness + if lightness >= cls.MAX_LIGHTNESS or lightness <= cls.MIN_LIGHTNESS: + mapcolor.chroma = 0 + mapcolor.hue = NaN + clip_channels(color.update(mapcolor)) + return - mapcolor.chroma = (high + low) * 0.5 - - # Update and clip off noise - return color.update(mapcolor).clip(space, in_place=True).coords() + low = 0.0 + high = mapcolor.chroma + clip_channels(color.update(mapcolor)) + + # If we are really close, skip gamut mapping and return the clipped value. + if mapcolor.delta_e(color, method=cls.DE) >= cls.LIMIT: + # Adjust chroma (using binary search). + # This helps preserve the other attributes of the color. + # Each time we compare the compressed color to it's clipped form + # to see how close we are. A delta less than JND is our lower bound + # and a value higher our upper. Continue until bounds converge. + while (high - low) > cls.EPSILON: + if mapcolor.delta_e(color, method=cls.DE) < cls.LIMIT: + low = mapcolor.chroma + else: + high = mapcolor.chroma + + mapcolor.chroma = (high + low) * 0.5 + clip_channels(color.update(mapcolor)) diff --git a/lib/coloraide/interpolate.py b/lib/coloraide/interpolate.py index 59cd9ac3..1a5adb5a 100644 --- a/lib/coloraide/interpolate.py +++ b/lib/coloraide/interpolate.py @@ -65,17 +65,23 @@ def __init__(self) -> None: """Initialize.""" @abstractmethod - def get_delta(self) -> Any: + def get_delta(self, method: Optional[str]) -> Any: """Get the delta.""" @abstractmethod def __call__(self, p: float) -> 'Color': """Call the interpolator.""" - def steps(self, steps: int = 2, max_steps: int = 1000, max_delta_e: float = 0) -> List['Color']: + def steps( + self, + steps: int = 2, + max_steps: int = 1000, + max_delta_e: float = 0, + delta_e: Optional[str] = None + ) -> List['Color']: """Steps.""" - return color_steps(self, steps, max_steps, max_delta_e) + return color_steps(self, steps, max_steps, max_delta_e, delta_e) class InterpolateSingle(Interpolator): @@ -103,10 +109,13 @@ def __init__( self.outspace = outspace self.premultiplied = premultiplied - def get_delta(self) -> float: + def get_delta(self, method: Optional[str]) -> float: """Get the delta.""" - return self.create(self.space, self.channels1).delta_e(self.create(self.space, self.channels2)) + return self.create(self.space, self.channels1).delta_e( + self.create(self.space, self.channels2), + method=method + ) def __call__(self, p: float) -> 'Color': """Run through the coordinates and run the interpolation on them.""" @@ -150,10 +159,10 @@ def __init__(self, stops: Dict[int, float], interpolators: List[InterpolateSingl self.stops = stops self.interpolators = interpolators - def get_delta(self) -> Vector: + def get_delta(self, method: Optional[str]) -> Vector: """Get the delta total.""" - return [i.get_delta() for i in self.interpolators] + return [i.get_delta(method) for i in self.interpolators] def __call__(self, p: float) -> 'Color': """Interpolate.""" @@ -335,7 +344,8 @@ def color_steps( interpolator: Interpolator, steps: int = 2, max_steps: int = 1000, - max_delta_e: float = 0 + max_delta_e: float = 0, + delta_e: Optional[str] = None ) -> List['Color']: """Color steps.""" @@ -343,7 +353,7 @@ def color_steps( actual_steps = steps else: actual_steps = 0 - deltas = interpolator.get_delta() + deltas = interpolator.get_delta(delta_e) if not isinstance(deltas, Sequence): deltas = [deltas] # Make a very rough guess of required steps. @@ -370,12 +380,17 @@ def color_steps( for i, entry in enumerate(ret): if i == 0: continue - m_delta = max(m_delta, cast('Color', entry['color']).delta_e(cast('Color', ret[i - 1]['color']))) + m_delta = max( + m_delta, + cast('Color', entry['color']).delta_e( + cast('Color', ret[i - 1]['color']), + method=delta_e + ) + ) # If we currently have delta over our limit inject more stops. # If inserting between every color would push us over the max_steps, halt. - count = len(ret) - while m_delta > max_delta_e and (count * 2 - 1 <= max_steps): + while m_delta > max_delta_e and (len(ret) * 2 - 1 <= max_steps): # Inject stops while measuring again to see if it was sufficient m_delta = 0.0 i = 1 @@ -386,8 +401,8 @@ def color_steps( color = interpolator(p) m_delta = max( m_delta, - color.delta_e(cast('Color', prev['color'])), - color.delta_e(cast('Color', cur['color'])) + color.delta_e(cast('Color', prev['color']), method=delta_e), + color.delta_e(cast('Color', cur['color']), method=delta_e) ) ret.insert(i, {'p': p, 'color': color}) i += 2 @@ -465,8 +480,9 @@ def color_lerp( """Color interpolation.""" # Convert to the color space and ensure the color fits inside - color1 = color1.convert(space, fit=True) - color2 = color1._handle_color_input(color2).convert(space, fit=True) + fit = not color1.CS_MAP[space].EXTENDED_RANGE + color1 = color1.convert(space, fit=fit) + color2 = color1._handle_color_input(color2).convert(space, fit=fit) # Adjust hues if we have two valid hues if isinstance(color1._space, Cylindrical): diff --git a/lib/coloraide/spaces/__init__.py b/lib/coloraide/spaces/__init__.py index 025ea7cd..2c1bf54b 100644 --- a/lib/coloraide/spaces/__init__.py +++ b/lib/coloraide/spaces/__init__.py @@ -171,6 +171,12 @@ class Space( # 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] + # When set to `True`, this denotes that the color space has the ability to represent out of gamut in colors in an + # extended range. When interpolation is done, if colors are interpolated in a smaller gamut than the colors being + # interpolated, the colors will usually be gamut mapped, but if the interpolation space happens to support extended + # ranges, then the colors will not be gamut mapped even if their gamut is larger than the target interpolation + # space. + EXTENDED_RANGE = False # Bounds of channels. Range could be suggested or absolute as not all spaces have definitive ranges. BOUNDS = tuple() # type: Tuple[Bounds, ...] # White point diff --git a/lib/coloraide/spaces/srgb/__init__.py b/lib/coloraide/spaces/srgb/__init__.py index fdefd114..6e536179 100644 --- a/lib/coloraide/spaces/srgb/__init__.py +++ b/lib/coloraide/spaces/srgb/__init__.py @@ -59,6 +59,7 @@ class SRGB(Space): } WHITE = "D65" + EXTENDED_RANGE = True BOUNDS = ( GamutBound(0.0, 1.0, FLG_OPT_PERCENT), GamutBound(0.0, 1.0, FLG_OPT_PERCENT), diff --git a/lib/coloraide/util.py b/lib/coloraide/util.py index d6234b0d..e58d72e7 100644 --- a/lib/coloraide/util.py +++ b/lib/coloraide/util.py @@ -22,8 +22,8 @@ DEF_ALPHA = 1.0 DEF_MIX = 0.5 DEF_HUE_ADJ = "shorter" -DEF_DISTANCE_SPACE = "lab" -DEF_FIT = "oklch-chroma" +DEF_INTERPOLATE = "oklab" +DEF_FIT = "lch-chroma" DEF_DELTA_E = "76" ERR_MAP_MSG = """ diff --git a/messages.json b/messages.json index de745fa3..92957456 100644 --- a/messages.json +++ b/messages.json @@ -1,4 +1,4 @@ { "install": "messages/install.md", - "4.1.0": "messages/recent.md" + "4.2.0": "messages/recent.md" } diff --git a/messages/recent.md b/messages/recent.md index ea861c88..a0e600f8 100644 --- a/messages/recent.md +++ b/messages/recent.md @@ -1,20 +1,20 @@ -# ColorHelper 4.1.0 +# ColorHelper 4.2.0 New release! -See `Preferences->Package Settings->ColorHelper->Changelog` for more info on +See `Preferences->Package Settings->ColorHelper->Changelog` for more info on prior releases. Restart of Sublime Text may be required. -## 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**: 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 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. diff --git a/support.py b/support.py index c06b2f23..61af49f9 100644 --- a/support.py +++ b/support.py @@ -5,7 +5,7 @@ import webbrowser import re -__version__ = "4.1.1" +__version__ = "4.2.0" __pc_name__ = 'ColorHelper' CSS = ''' From cd1f66a810c1e4e99790f70c1903a58ca2752300 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 19 Feb 2022 08:00:38 -0700 Subject: [PATCH 2/4] Shuffle order of colors in CSS Level 4 conversion list --- color_helper.sublime-settings | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/color_helper.sublime-settings b/color_helper.sublime-settings index 9bceb4bd..fc6d92e4 100755 --- a/color_helper.sublime-settings +++ b/color_helper.sublime-settings @@ -174,10 +174,10 @@ {"space": "srgb", "format": {"comma": true}}, {"space": "hsl", "format": {"comma": true}}, {"space": "hwb", "format": {"comma": false}}, + {"space": "a98-rgb", "format": {}}, {"space": "display-p3", "format": {}}, - {"space": "rec2020", "format": {}}, {"space": "prophoto-rgb", "format": {}}, - {"space": "a98-rgb", "format": {}}, + {"space": "rec2020", "format": {}}, {"space": "srgb-linear", "format": {}}, {"space": "xyz-d65", "format": {}}, {"space": "xyz-d50", "format": {}}, From 9de1e8a0dcc3ad584f7b20ad939fa52b57f4e9c0 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 19 Feb 2022 08:56:34 -0700 Subject: [PATCH 3/4] Bump copyright date --- LICENSE | 2 +- README.md | 2 +- mkdocs.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index deaa26cd..e106fb2f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015 - 2021 Isaac Muse +Copyright (c) 2015 - 2022 Isaac Muse Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.md b/README.md index 0d0fceaf..a0d249f5 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ https://facelessuser.github.io/ColorHelper/ ColorHelper is released under the MIT license. -Copyright (c) 2015 - 2021 Isaac Muse +Copyright (c) 2015 - 2022 Isaac Muse Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the diff --git a/mkdocs.yml b/mkdocs.yml index 76e95a0b..e92e5f25 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,7 +4,7 @@ repo_url: https://github.com/facelessuser/ColorHelper edit_uri: tree/master/docs/src/markdown site_description: Popup tooltips and previews for stylesheet colors. copyright: | - Copyright © 2015 - 2021 Isaac Muse + Copyright © 2015 - 2022 Isaac Muse docs_dir: docs/src/markdown theme: From d36a048b1cb0b8494a40db2b9da01681585b53eb Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sat, 19 Feb 2022 16:36:31 -0700 Subject: [PATCH 4/4] Fix some typos --- CHANGES.md | 2 +- docs/src/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6e387655..0148775c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,7 +6,7 @@ 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 mature and be tested more. + 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 diff --git a/docs/src/requirements.txt b/docs/src/requirements.txt index 119ba29b..83f60a77 100644 --- a/docs/src/requirements.txt +++ b/docs/src/requirements.txt @@ -1,4 +1,4 @@ -mkdocs_pymdownx_material_extras>=1.5.7 +mkdocs_pymdownx_material_extras>=1.6 mkdocs-git-revision-date-localized-plugin mkdocs-minify-plugin pyspelling