From 388a51d5c528cec4e9984763112f80032c3054b9 Mon Sep 17 00:00:00 2001 From: Isaac Muse Date: Thu, 19 Aug 2021 11:16:18 -0600 Subject: [PATCH 01/12] Serialize adjust (#188) * Upgrade to alpha 21 of coloraide which fixes gamut mapping issues * Small gamut fitting and serialize adjustments and upgrade coloraide * Update coloraide to alpha 23 * Update doc dependency * Ignore N818 lint code --- CHANGES.md | 2 + ch_mixin.py | 15 +- ch_panel.py | 12 +- ch_picker.py | 12 +- ch_preview.py | 7 +- ch_tool_blend.py | 7 +- ch_tool_colormod.py | 7 +- ch_tool_contrast.py | 4 +- ch_tool_diff.py | 7 +- ch_tool_edit.py | 7 +- ch_util.py | 8 +- custom/st_colormod.py | 6 +- docs/src/requirements.txt | 2 +- lib/coloraide/__meta__.py | 2 +- lib/coloraide/color/__init__.py | 1 + .../color/{convert.py => convert/__init__.py} | 17 +- lib/coloraide/color/convert/cat.py | 115 +++++++++++ lib/coloraide/color/distance/delta_e_itp.py | 8 +- lib/coloraide/color/gamut/__init__.py | 4 +- lib/coloraide/color/gamut/clip.py | 2 +- lib/coloraide/color/gamut/lch_chroma.py | 81 ++++---- lib/coloraide/color/interpolate.py | 132 ++++++------ lib/coloraide/spaces/__init__.py | 19 +- lib/coloraide/spaces/_cat.py | 48 ----- lib/coloraide/spaces/a98_rgb.py | 23 +-- lib/coloraide/spaces/display_p3.py | 17 +- lib/coloraide/spaces/hsl.py | 16 +- lib/coloraide/spaces/hsv.py | 25 ++- lib/coloraide/spaces/hwb.py | 33 ++- lib/coloraide/spaces/ictcp.py | 11 +- lib/coloraide/spaces/jzazbz.py | 25 ++- lib/coloraide/spaces/jzczhz.py | 15 +- lib/coloraide/spaces/lab.py | 19 +- lib/coloraide/spaces/lab_d65.py | 11 +- lib/coloraide/spaces/lch.py | 15 +- lib/coloraide/spaces/lch_d65.py | 15 +- lib/coloraide/spaces/oklab.py | 25 ++- lib/coloraide/spaces/oklch.py | 15 +- lib/coloraide/spaces/prophoto_rgb.py | 17 +- lib/coloraide/spaces/rec2020.py | 17 +- lib/coloraide/spaces/srgb.py | 26 ++- lib/coloraide/spaces/srgb_linear.py | 15 +- lib/coloraide/spaces/xyz.py | 3 +- lib/coloraide/spaces/xyz_d65.py | 11 +- lib/coloraide/util.py | 188 +++++++++++++----- tox.ini | 2 +- 46 files changed, 625 insertions(+), 444 deletions(-) rename lib/coloraide/color/{convert.py => convert/__init__.py} (82%) create mode 100644 lib/coloraide/color/convert/cat.py delete mode 100644 lib/coloraide/spaces/_cat.py diff --git a/CHANGES.md b/CHANGES.md index 03aea04e..3ed5a75c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,8 @@ 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. ## 3.4.0 diff --git a/ch_mixin.py b/ch_mixin.py index 13eca483..d72dd8a1 100644 --- a/ch_mixin.py +++ b/ch_mixin.py @@ -7,7 +7,7 @@ from .lib.coloraide import Color from collections import namedtuple -SPACER = Color("transparent", filters=util.SRGB_SPACES).to_string(**HEX) +SPACER = Color("transparent", filters=util.CSS_SRGB_SPACES).to_string(**HEX) class Preview(namedtuple('Preview', ['preview1', 'preview2', 'border', 'message'])): @@ -30,7 +30,7 @@ def setup_image_border(self): border_color = ch_settings.get('image_border_color') if border_color is not None: try: - border_color = Color(border_color, filters=util.SRGB_SPACES) + border_color = Color(border_color, filters=util.CSS_SRGB_SPACES) border_color.fit("srgb", in_place=True) except Exception: border_color = None @@ -39,15 +39,15 @@ def setup_image_border(self): # Calculate border color for images border_color = Color( self.view.style()['background'], - filters=util.SRGB_SPACES + filters=util.CSS_SRGB_SPACES ).convert("hsl") border_color.lightness = border_color.lightness + (30 if border_color.luminance() < 0.5 else -30) self.default_border = border_color.convert("srgb").to_string(**HEX) - self.out_of_gamut = Color("transparent", filters=util.SRGB_SPACES).to_string(**HEX) + self.out_of_gamut = Color("transparent", filters=util.CSS_SRGB_SPACES).to_string(**HEX) self.out_of_gamut_border = Color( self.view.style().get('redish', "red"), - filters=util.SRGB_SPACES + filters=util.CSS_SRGB_SPACES ).to_string(**HEX) def get_color_options(self, pt, rule): @@ -174,7 +174,8 @@ def get_preview(self, color): message = '' preview_border = self.default_border - if not color.in_gamut("srgb"): + check_space = 'srgb' if color.space() not in util.SRGB_SPACES else color.space() + if not color.in_gamut(check_space): message = 'preview out of gamut' if self.show_out_of_gamut_preview: srgb = color.convert("srgb", fit=True) @@ -185,7 +186,7 @@ def get_preview(self, color): preview2 = self.out_of_gamut preview_border = self.out_of_gamut_border else: - srgb = color.convert("srgb") + srgb = color.convert("srgb", fit=True) preview1 = srgb.to_string(**HEX_NA) preview2 = srgb.to_string(**HEX) diff --git a/ch_panel.py b/ch_panel.py index 2df9ae0d..964c772b 100755 --- a/ch_panel.py +++ b/ch_panel.py @@ -119,6 +119,8 @@ def prompt_palette_name(self, palette_type, color): def create_palette(self, palette_name, palette_type, color): """Add color to new color palette.""" + color = Color(color).to_string(**util.COLOR_SERIALIZE) + if palette_type == '__global__': color_palettes = util.get_palettes() for palette in color_palettes: @@ -140,6 +142,8 @@ def create_palette(self, palette_name, palette_type, color): def add_palette(self, color, palette_type, palette_name): """Add palette.""" + color = Color(color).to_string(**util.COLOR_SERIALIZE) + if palette_type == "__special__": if palette_name == 'Favorites': favs = util.get_favs()['colors'] @@ -220,7 +224,7 @@ def add_fav(self, color): """Add favorite.""" favs = util.get_favs()['colors'] - favs.append(color) + favs.append(Color(color).to_string(**util.COLOR_SERIALIZE)) util.save_palettes(favs, favs=True) # For some reason if using update, # the convert divider will be too wide. @@ -230,7 +234,7 @@ def remove_fav(self, color): """Remove favorite.""" favs = util.get_favs()['colors'] - favs.remove(color) + favs.remove(Color(color).to_string(**util.COLOR_SERIALIZE)) util.save_palettes(favs, favs=True) # For some reason if using update, # the convert divider will be too wide. @@ -470,7 +474,7 @@ def format_info(self, obj, template_vars): template_vars['show_global_palette_menu'] = True if show_favorite_palette and color_ver_okay: template_vars['show_favorite_menu'] = True - template_vars['is_marked'] = color.to_string(**util.COLOR_FULL_PREC) in util.get_favs()['colors'] + template_vars['is_marked'] = color.to_string(**util.COLOR_SERIALIZE) in util.get_favs()['colors'] preview = self.get_preview(color) message = '' @@ -766,7 +770,7 @@ def run(self, edit, mode, palette_name=None, color=None, insert_raw=None, result self.no_info = True obj = self.get_cursor_color() if obj is None: - color = Color("white", filters=util.SRGB_SPACES).to_string(**util.COLOR_FULL_PREC) + color = Color("white", filters=util.CSS_SRGB_SPACES).to_string(**util.COLOR_FULL_PREC) else: color = obj.color.to_string(**util.COLOR_FULL_PREC) self.color_picker(color) diff --git a/ch_picker.py b/ch_picker.py index cf957db4..18f20778 100644 --- a/ch_picker.py +++ b/ch_picker.py @@ -93,7 +93,7 @@ def get_color_map_square(self): # Generate the colors with each row being darker than the last. # Each column will progress through hues. - color = Color("hsl(0 100% {}% / {})".format(lightness, alpha), filters=util.SRGB_SPACES) + color = Color("hsl(0 100% {}% / {})".format(lightness, alpha), filters=util.CSS_SRGB_SPACES) if color.is_nan("hue"): color.hue = 0.0 check_size = self.check_size(self.height) @@ -150,7 +150,7 @@ def get_color_map_square(self): color.saturation = color.saturation - 10 # Generate a grayscale bar. - color = Color('hsl({} {}% 100% / {})'.format(hue, saturation, alpha), filters=util.SRGB_SPACES) + color = Color('hsl({} {}% 100% / {})'.format(hue, saturation, alpha), filters=util.CSS_SRGB_SPACES) if color.is_nan("hue"): color.hue = 0.0 check_size = self.check_size(self.height) @@ -216,7 +216,7 @@ def get_css_color_names(self): check_size = self.check_size(self.height) html = [] for name in sorted(css_names.name2hex_map): - color = Color(name, filters=util.SRGB_SPACES) + color = Color(name, filters=util.CSS_SRGB_SPACES) html.append( '[{}]({}) {}
'.format( @@ -331,7 +331,7 @@ def get_channel(self, channel, label, color_filter): coord = cutil.no_nan(getattr(clone, color_filter)) - step setattr(clone, color_filter, coord) - if not clone.in_gamut(): + if not clone.in_gamut(tolerance=0): temp.append(self.get_spacer(width=count)) break elif color_filter == "alpha" and (coord < 0 or coord > 1.0): @@ -382,7 +382,7 @@ def get_channel(self, channel, label, color_filter): coord = cutil.no_nan(getattr(clone, color_filter)) + step setattr(clone, color_filter, coord) - if not clone.in_gamut(): + if not clone.in_gamut(tolerance=0): html.append(self.get_spacer(width=count)) break elif color_filter == "alpha" and (coord < 0 or coord > 1.0): @@ -515,7 +515,7 @@ def handle_href(self, href): self.view.run_command( cmd, { - "initial": Color(color, filters=util.SRGB_SPACES).to_string(**DEFAULT), + "initial": Color(color, filters=util.CSS_SRGB_SPACES).to_string(**DEFAULT), "on_done": on_done, "on_cancel": on_cancel } ) diff --git a/ch_preview.py b/ch_preview.py index f12be445..5bb40cdb 100644 --- a/ch_preview.py +++ b/ch_preview.py @@ -413,14 +413,15 @@ def do_search(self, force=False): # Calculate a reasonable border color for our image at this location and get color strings hsl = Color( mdpopups.scope2style(self.view, self.view.scope_name(pt))['background'], - filters=util.SRGB_SPACES + filters=util.CSS_SRGB_SPACES ).convert("hsl") hsl.lightness = hsl.lightness + (30 if hsl.luminance() < 0.5 else -30) preview_border = hsl.convert("srgb", fit=True).to_string(**util.HEX) color = Color(obj.color) title = '' - if not color.in_gamut("srgb"): + check_space = 'srgb' if color.space() not in util.SRGB_SPACES else color.space() + if not color.in_gamut(check_space): title = ' title="Preview out of gamut"' if self.show_out_of_gamut_preview: srgb = color.convert("srgb", fit=True) @@ -431,7 +432,7 @@ def do_search(self, force=False): preview2 = self.out_of_gamut preview_border = self.out_of_gamut_border else: - srgb = color.convert("srgb") + srgb = color.convert("srgb", fit=True) preview1 = srgb.to_string(**util.HEX_NA) preview2 = srgb.to_string(**util.HEX) diff --git a/ch_tool_blend.py b/ch_tool_blend.py index 572e6e98..1a5e0d82 100644 --- a/ch_tool_blend.py +++ b/ch_tool_blend.py @@ -178,13 +178,12 @@ def preview(self, text): orig = Color(color) message = "" color_string = "" - if not orig.in_gamut('srgb'): + check_space = 'srgb' if orig.space() not in util.SRGB_SPACES else orig.space() + if not orig.in_gamut(check_space): orig = orig.fit("srgb") message = '
* preview out of gamut' color_string = "Gamut Mapped: {}
".format(orig.to_string()) - srgb = orig.convert('srgb', fit=True) - else: - srgb = orig.convert('srgb') + srgb = orig.convert('srgb', fit=True) color_string += "Color: {}".format(color.to_string(**util.DEFAULT)) preview = srgb.to_string(**util.HEX_NA) preview_alpha = srgb.to_string(**util.HEX) diff --git a/ch_tool_colormod.py b/ch_tool_colormod.py index 6f8555c0..a3107efa 100644 --- a/ch_tool_colormod.py +++ b/ch_tool_colormod.py @@ -65,7 +65,7 @@ def initial_text(self): # Basically, if the file already supports `color-mod` input, # then we want to return the text raw if it parses. try: - color = self.color_mod_class(text, filters=util.SRGB_SPACES) + color = self.color_mod_class(text, filters=util.CSS_SRGB_SPACES) except Exception: pass if color is None: @@ -93,9 +93,10 @@ def preview(self, text): srgb = Color(color).convert("srgb") preview_border = self.default_border message = "" - if not srgb.in_gamut(): - srgb.fit() + check_space = 'srgb' if srgb.space() not in util.SRGB_SPACES else srgb.space() + if not srgb.in_gamut(check_space): message = '
* preview out of gamut' + srgb.fit(in_place=True) preview = srgb.to_string(**util.HEX_NA) preview_alpha = srgb.to_string(**util.HEX) preview_border = self.default_border diff --git a/ch_tool_contrast.py b/ch_tool_contrast.py index b26f20f9..cb83ef85 100644 --- a/ch_tool_contrast.py +++ b/ch_tool_contrast.py @@ -56,7 +56,7 @@ def parse_color(string, start=0, second=False): more = None ratio = None # First color - color = Color.match(string, start=start, fullmatch=False, filters=util.SRGB_SPACES) + color = Color.match(string, start=start, fullmatch=False, filters=util.CSS_SRGB_SPACES) if color: start = color.end if color.end != length: @@ -178,7 +178,7 @@ def initial_text(self): pass if color is not None: color = Color(color) - if color.space() not in util.SRGB_SPACES: + if color.space() not in util.CSS_SRGB_SPACES: color = color.convert("srgb", fit=True) return color.to_string(**util.DEFAULT) return '' diff --git a/ch_tool_diff.py b/ch_tool_diff.py index 7efa7cc1..929a5e2a 100644 --- a/ch_tool_diff.py +++ b/ch_tool_diff.py @@ -179,13 +179,12 @@ def preview(self, text): orig = Color(color) message = "" color_string = "" - if not orig.in_gamut('srgb'): + check_space = 'srgb' if orig.space() not in util.SRGB_SPACES else orig.space() + if not orig.in_gamut(check_space): orig = orig.fit("srgb") message = '
* preview out of gamut' color_string = "Gamut Mapped: {}
".format(orig.to_string()) - srgb = orig.convert('srgb', fit=True) - else: - srgb = orig.convert('srgb') + srgb = orig.convert('srgb', fit=True) color_string += "Color: {}".format(color.to_string(**util.DEFAULT)) preview = srgb.to_string(**util.HEX_NA) preview_alpha = srgb.to_string(**util.HEX) diff --git a/ch_tool_edit.py b/ch_tool_edit.py index ccb46613..22975749 100644 --- a/ch_tool_edit.py +++ b/ch_tool_edit.py @@ -215,13 +215,12 @@ def preview(self, text): orig = Color(color) message = "" color_string = "" - if not orig.in_gamut('srgb'): + check_space = 'srgb' if orig.space() not in util.SRGB_SPACES else orig.space() + if not orig.in_gamut(check_space): orig = orig.fit("srgb") message = '
* preview out of gamut' color_string = "Gamut Mapped: {}
".format(orig.to_string()) - srgb = orig.convert('srgb', fit=True) - else: - srgb = orig.convert('srgb') + srgb = orig.convert('srgb', fit=True) color_string += "Color: {}".format(color.to_string(**util.DEFAULT)) preview = srgb.to_string(**util.HEX_NA) preview_alpha = srgb.to_string(**util.HEX) diff --git a/ch_util.py b/ch_util.py index 86c41f56..fe17fa08 100644 --- a/ch_util.py +++ b/ch_util.py @@ -30,7 +30,9 @@ COMMA = {"fit": False, "comma": True} FULL_PREC = {"fit": False, "precision": -1} COLOR_FULL_PREC = {"color": True, "fit": False, "precision": -1} -SRGB_SPACES = ("srgb", "hsl", "hwb") +COLOR_SERIALIZE = {"color": True, "fit": False, "precision": -1} +SRGB_SPACES = ("srgb", "hsl", "hwb", "hsv") +CSS_SRGB_SPACES = ("srgb", "hsl", "hwb") CSS_L4_SPACES = ("srgb", "hsl", "hwb", "lch", "lab", "display-p3", "rec2020", "prophoto-rgb", "a98-rgb", "xyz") lang_map = { @@ -245,9 +247,9 @@ def update_colors_1_0(colors): alpha = float(values[1]) else: alpha = 1 - new_colors.append(Color(m.group(1), channels, alpha).to_string(**COLOR_FULL_PREC)) + new_colors.append(Color(m.group(1), channels, alpha).to_string(**COLOR_SERIALIZE)) else: - new_colors.append(Color(c).to_string(**COLOR_FULL_PREC)) + new_colors.append(Color(c).to_string(**COLOR_SERIALIZE)) except Exception: pass return new_colors diff --git a/custom/st_colormod.py b/custom/st_colormod.py index c4a38c1e..5eb275b6 100644 --- a/custom/st_colormod.py +++ b/custom/st_colormod.py @@ -235,8 +235,7 @@ def _adjust(self, string, start=0): if color is not None: self._color = color - if not self._color.in_gamut(): - self._color.fit(method="clip", in_place=True) + self._color.fit(method="clip", in_place=True) while not done: m = None @@ -264,8 +263,7 @@ def _adjust(self, string, start=0): else: break - if not self._color.in_gamut(): - self._color.fit(method="clip", in_place=True) + self._color.fit(method="clip", in_place=True) else: raise ValueError('Could not calculate base color') except Exception: diff --git a/docs/src/requirements.txt b/docs/src/requirements.txt index 299e5aff..29804f9e 100644 --- a/docs/src/requirements.txt +++ b/docs/src/requirements.txt @@ -1,4 +1,4 @@ -mkdocs_pymdownx_material_extras>=1.2.2 +mkdocs_pymdownx_material_extras>=1.4 mkdocs-git-revision-date-localized-plugin mkdocs-minify-plugin pyspelling diff --git a/lib/coloraide/__meta__.py b/lib/coloraide/__meta__.py index 901f0111..89a826bd 100644 --- a/lib/coloraide/__meta__.py +++ b/lib/coloraide/__meta__.py @@ -188,5 +188,5 @@ def parse_version(ver): return Version(major, minor, micro, release, pre, post, dev) -__version_info__ = Version(0, 1, 0, "alpha", 19) +__version_info__ = Version(0, 1, 0, "alpha", 23) __version__ = __version_info__._get_canonical() diff --git a/lib/coloraide/color/__init__.py b/lib/coloraide/color/__init__.py index 277db793..049c2837 100644 --- a/lib/coloraide/color/__init__.py +++ b/lib/coloraide/color/__init__.py @@ -53,6 +53,7 @@ class Color( PRECISION = util.DEF_PREC FIT = util.DEF_FIT DELTA_E = util.DEF_DELTA_E + CHROMATIC_ADAPTATION = 'bradford' def __init__(self, color, data=None, alpha=util.DEF_ALPHA, *, filters=None, **kwargs): """Initialize.""" diff --git a/lib/coloraide/color/convert.py b/lib/coloraide/color/convert/__init__.py similarity index 82% rename from lib/coloraide/color/convert.py rename to lib/coloraide/color/convert/__init__.py index 6c04c589..8d377bbf 100644 --- a/lib/coloraide/color/convert.py +++ b/lib/coloraide/color/convert/__init__.py @@ -1,10 +1,17 @@ """Convert utilities.""" -from .. import util +from ... import util +from . import cat class Convert: """Conversion methods.""" + def chromatic_adaptation(self, w1, w2, xyz): + """Apply chromatic adaption to XYZ coordinates.""" + + method = self.CHROMATIC_ADAPTATION + return cat.chromatic_adaptation(w1, w2, xyz, method=method) + def convert(self, space, *, fit=False, in_place=False): """Convert to color space.""" @@ -28,19 +35,19 @@ def convert(self, space, *, fit=False, in_place=False): coords = self.coords() if hasattr(self._space, convert_to): func = getattr(self._space, convert_to) - coords = func(coords) + coords = func(self, coords) elif hasattr(obj, convert_from): func = getattr(obj, convert_from) - coords = func(coords) + coords = func(self, coords) # See if there is an XYZ route if func is None and self.space() != space: func = getattr(self._space, '_to_xyz') - coords = func(coords) + coords = func(self, coords) if space != 'xyz': func = getattr(obj, '_from_xyz') - coords = func(coords) + coords = func(self, coords) return self.mutate(space, coords, self.alpha) if in_place else self.new(space, coords, self.alpha) diff --git a/lib/coloraide/color/convert/cat.py b/lib/coloraide/color/convert/cat.py new file mode 100644 index 00000000..5a5cfd68 --- /dev/null +++ b/lib/coloraide/color/convert/cat.py @@ -0,0 +1,115 @@ +"""Chromatic adaptation transforms.""" +from ... import util +from ... spaces import WHITES +from functools import lru_cache + +# Conversion matrices +CATS = { + "bradford": [ + # http://brucelindbloom.com/Eqn_ChromAdapt.html + # https://hrcak.srce.hr/file/95370 + [0.8951000, 0.2664000, -0.1614000], + [-0.7502000, 1.7135000, 0.0367000], + [0.0389000, -0.0685000, 1.0296000] + ], + "von-kries": [ + # http://brucelindbloom.com/Eqn_ChromAdapt.html + # https://hrcak.srce.hr/file/95370 + [0.4002400, 0.7076000, -0.0808100], + [-0.2263000, 1.1653200, 0.0457000], + [0.0000000, 0.0000000, 0.9182200] + ], + "xyz-scaling": [ + # http://brucelindbloom.com/Eqn_ChromAdapt.html + # https://hrcak.srce.hr/file/95370 + [1, 0, 0], + [0, 1, 0], + [0, 0, 1] + ], + "cat02": [ + # https://en.wikipedia.org/wiki/CIECAM02#CAT02 + [0.7328000, 0.4296000, -0.1624000], + [-0.7036000, 1.6975000, 0.0061000], + [0.0030000, 0.0136000, 0.9834000] + ], + "cmccat97": [ + # https://hrcak.srce.hr/file/95370 + [0.8951000, -0.7502000, 0.0389000], + [0.2664000, 1.7135000, 0.0685000], + [-0.1614000, 0.0367000, 1.0296000], + ], + "sharp": [ + # https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.14.918&rep=rep1&type=pdf + [1.2694000, -0.0988000, -0.1706000], + [-0.8364000, 1.8006000, 0.0357000], + [0.0297000, -0.0315000, 1.0018000] + ], + 'cmccat2000': [ + # https://hrcak.srce.hr/file/95370 + # https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.14.918&rep=rep1&type=pdf + [0.7982000, 0.3389000, -0.1371000], + [-0.5918000, 1.5512000, 0.0406000], + [0.0008000, 0.0239000, 0.9753000] + ] +} + + +@lru_cache(maxsize=20) +def calc_adaptation_matrices(w1, w2, method='bradford'): + """ + Get the von Kries based adaptation matrix based on the method and illuminants. + + Since these calculated matrices are cached, this greatly reduces + performance hit as the initial matrices only have to be calculated + once for a given pair of white points and CAT. + + 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. + """ + + try: + m = CATS[method] + except KeyError: # pragma: no cover + raise ValueError('Unknown chromatic adaptation method encountered: {}'.format(method)) + mi = util.inv(m) + + try: + first = util.dot(m, WHITES[w1]) + except KeyError: # pragma: no cover + raise ValueError('Unknown white point encountered: {}'.format(w1)) + + try: + second = util.dot(m, WHITES[w2]) + except KeyError: # pragma: no cover + raise ValueError('Unknown white point encountered: {}'.format(w2)) + + m2 = util.diag(util.divide(first, second)) + adapt = util.dot(mi, util.dot(m2, m)) + + return adapt, util.inv(adapt) + + +def get_adaptation_matrix(w1, w2, method): + """ + Get the appropriate matrix for chromatic adaptation. + + If the required matrices are not in the cache, they will be calculated. + Since white points are sorted by name, regardless of the requested + conversion direction, the same matrices will be retrieved from the cache. + """ + + a, b = sorted([w1, w2]) + m, mi = calc_adaptation_matrices(a, b, method) + return mi if a != w2 else m + + +def chromatic_adaptation(w1, w2, xyz, method='bradford'): + """Chromatic adaptation.""" + + if w1 == w2: + # No adaptation is needed if the white points are identical. + return xyz + else: + # Get the appropriate chromatic adaptation matrix and apply. + return util.dot(get_adaptation_matrix(w1, w2, method), xyz) diff --git a/lib/coloraide/color/distance/delta_e_itp.py b/lib/coloraide/color/distance/delta_e_itp.py index 5fb8128f..101912be 100644 --- a/lib/coloraide/color/distance/delta_e_itp.py +++ b/lib/coloraide/color/distance/delta_e_itp.py @@ -7,13 +7,7 @@ def distance(color1, color2, scalar=720, **kwargs): - """ - Delta E 1976 color distance formula. - - http://www.brucelindbloom.com/index.html?Eqn_DeltaE_CIE76.html - - Basically this is Euclidean distance in the Lab space. - """ + """Delta E ITP color distance formula.""" i1, t1, p1 = color1.convert('ictcp').coords() i2, t2, p2 = color2.convert('ictcp').coords() diff --git a/lib/coloraide/color/gamut/__init__.py b/lib/coloraide/color/gamut/__init__.py index 009c5ac5..96d4336b 100644 --- a/lib/coloraide/color/gamut/__init__.py +++ b/lib/coloraide/color/gamut/__init__.py @@ -52,7 +52,9 @@ def fit(self, space=None, *, method=None, in_place=False): # If we are perfectly in gamut, don't waste time fitting, just normalize hues. # If out of gamut, apply mapping/clipping/etc. - c._space._coords = norm_angles(c) if c.in_gamut(tolerance=0.0) else func(self.clone(), c) + c._space._coords, c._space._alpha = ( + c._space.null_adjust(norm_angles(c) if c.in_gamut(tolerance=0.0) else func(c), self.alpha) + ) # Adjust "this" color return this.update(c) diff --git a/lib/coloraide/color/gamut/clip.py b/lib/coloraide/color/gamut/clip.py index 340bdbef..a1f50994 100644 --- a/lib/coloraide/color/gamut/clip.py +++ b/lib/coloraide/color/gamut/clip.py @@ -3,7 +3,7 @@ from ... spaces import Angle, GamutBound -def fit(base, color): +def fit(color): """Gamut clipping.""" channels = util.no_nan(color.coords()) diff --git a/lib/coloraide/color/gamut/lch_chroma.py b/lib/coloraide/color/gamut/lch_chroma.py index 234472c1..0c7235c3 100644 --- a/lib/coloraide/color/gamut/lch_chroma.py +++ b/lib/coloraide/color/gamut/lch_chroma.py @@ -1,11 +1,14 @@ """Fit by compressing chroma in Lch.""" +EPSILON = 0.001 -def fit(base, color): + +def fit(color): """ Gamut mapping via chroma Lch. - Algorithm comes from https://colorjs.io/docs/gamut-mapping.html. + Algorithm originally came from https://colorjs.io/docs/gamut-mapping.html. + Some things have been optimized and fixed though to better perform as intended. The idea is to hold hue and lightness constant and decrease lightness until color comes under gamut. @@ -19,41 +22,41 @@ def fit(base, color): License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json) """ - # Compare clipped against original to - # judge how far we are off with the worst case fitting space = color.space() - clipped = color.clone() - clipped.fit(space=space, method="clip", in_place=True) - base_error = base.delta_e(clipped, method="2000") - - if base_error > 2.3: - threshold = .001 - # Compare mapped against desired space - mapcolor = color.convert("lch") - error = color.delta_e(mapcolor, method="2000") - low = 0.0 - high = mapcolor.chroma - - # Adjust chroma (using binary search). - # This helps preserve the color more (in most cases). - # After each adjustment, see if clipping gets us close enough. - while (high - low) > threshold and error < base_error: - clipped = mapcolor.clone() - clipped.fit(space, method="clip", in_place=True) - delta = mapcolor.delta_e(clipped, method="2000") - error = color.delta_e(mapcolor, method="2000") - if delta - 2 < threshold: - low = mapcolor.chroma - else: - if abs(delta - 2) < threshold: # pragma: no cover - # Can this occur? - break - high = mapcolor.chroma - mapcolor.chroma = (high + low) / 2 - # Trim off noise allowed by our tolerance - color.update(mapcolor) - color.fit(space, method="clip", in_place=True) - else: - # We are close enough that we should just clip. - color.update(clipped) - return color.coords() + + # If flooring chroma doesn't work, just clip the floored color + # because there is no optimal compression. + floor = color.clone().set('lch.chroma', 0) + if not floor.in_gamut(tolerance=0): + return floor.fit(method="clip").coords() + + # If we are already below the JND, just clip as we will gain no + # noticeable difference moving forward. + clipped = color.fit(method="clip") + if color.delta_e(clipped, method="2000") < 2: + return clipped.coords() + + # Convert to CIELCH and set our boundaries + mapcolor = color.convert("lch") + 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) > EPSILON: + delta = mapcolor.delta_e( + mapcolor.fit(space, method="clip"), + method="2000" + ) + + if (delta - 2) < EPSILON: + low = mapcolor.chroma + else: + high = mapcolor.chroma + + mapcolor.chroma = (high + low) * 0.5 + + # Update and clip off noise + return color.update(mapcolor).fit(space, method="clip", in_place=True).coords() diff --git a/lib/coloraide/color/interpolate.py b/lib/coloraide/color/interpolate.py index 536cf7d4..22c083ad 100644 --- a/lib/coloraide/color/interpolate.py +++ b/lib/coloraide/color/interpolate.py @@ -56,6 +56,15 @@ def __init__(self): def __call__(self, p): """Call the interpolator.""" + @abstractmethod + def get_delta(self): + """Initialize.""" + + def steps(self, steps=2, max_steps=1000, max_delta_e=0): + """Steps.""" + + return color_steps(self, steps, max_steps, max_delta_e) + class InterpolateSingle(Interpolator): """Interpolate a single range of two colors.""" @@ -72,6 +81,11 @@ def __init__(self, channels1, channels2, names, create, progress, space, outspac self.outspace = outspace self.premultiplied = premultiplied + def get_delta(self): + """Get the delta.""" + + return self.create(self.space, self.channels1).delta_e(self.create(self.space, self.channels2)) + def __call__(self, p): """Run through the coordinates and run the interpolation on them.""" @@ -111,6 +125,11 @@ def __init__(self, stops, interpolators): self.stops = stops self.interpolators = interpolators + def get_delta(self): + """Get the delta total.""" + + return [i.get_delta() for i in self.interpolators] + def __call__(self, p): """Interpolate.""" @@ -287,6 +306,58 @@ def adjust_hues(color1, color2, hue): color2.set(name, c2) +def color_steps(interpolator, steps=2, max_steps=1000, max_delta_e=0): + """Color steps.""" + + if max_delta_e <= 0: + actual_steps = steps + else: + actual_steps = 0 + deltas = interpolator.get_delta() + if not isinstance(deltas, Sequence): + deltas = [deltas] + actual_steps = sum([d / max_delta_e for d in deltas]) + actual_steps = max(steps, math.ceil(actual_steps) + 1) + + if max_steps is not None: + actual_steps = min(actual_steps, max_steps) + + ret = [] + if actual_steps == 1: + ret = [{"p": 0.5, "color": interpolator(0.5)}] + else: + step = 1 / (actual_steps - 1) + for i in range(actual_steps): + p = i * step + ret.append({'p': p, 'color': interpolator(p)}) + + # Iterate over all the stops inserting stops in between if all colors + # if we have any two colors with a max delta greater than what was requested. + # We inject between every stop to ensure the midpoint does not shift. + if max_delta_e > 0: + # Initial check to see if we need to insert more stops + m_delta = 0 + for i, entry in enumerate(ret): + if i == 0: + continue + m_delta = max(m_delta, entry['color'].delta_e(ret[i - 1]['color'])) + + while m_delta > max_delta_e: + # Inject stops while measuring again to see if it was sufficient + m_delta = 0 + i = 1 + while i < len(ret) and len(ret) < max_steps: + prev = ret[i - 1] + cur = ret[i] + p = (cur['p'] + prev['p']) / 2 + color = interpolator(p) + m_delta = max(m_delta, color.delta_e(prev['color']), color.delta_e(cur['color'])) + ret.insert(i, {'p': p, 'color': color}) + i += 2 + + return [i['color'] for i in ret] + + def color_piecewise_lerp(pw, space, out_space, progress, hue, premultiplied): """Piecewise Interpolation.""" @@ -387,66 +458,7 @@ def steps(self, color, *, steps=2, max_steps=1000, max_delta_e=0, **interpolate_ Default delta E method used is delta E 76. """ - interpolator = self.interpolate(color, **interpolate_args) - - if isinstance(color, Piecewise): - color = self._handle_color_input(color.color) - elif not isinstance(color, str) and isinstance(color, Sequence): - color = [self._handle_color_input(c.color if isinstance(c, Piecewise) else c) for c in color] - - color = self._handle_color_input(color, sequence=True) - - if not isinstance(color, Sequence) and max_delta_e > 0: - color = [self, color] - - if max_delta_e <= 0: - actual_steps = steps - else: - actual_steps = 0 - current = self - for c in color: - total_delta = current.delta_e(c) - actual_steps += total_delta / max_delta_e - current = c - actual_steps = max(steps, math.ceil(actual_steps) + 1) - - if max_steps is not None: - actual_steps = min(actual_steps, max_steps) - - ret = [] - if actual_steps == 1: - ret = [{"p": 0.5, "color": interpolator(0.5)}] - else: - step = 1 / (actual_steps - 1) - for i in range(actual_steps): - p = i * step - ret.append({'p': p, 'color': interpolator(p)}) - - # Iterate over all the stops inserting stops in between if all colors - # if we have any two colors with a max delta greater than what was requested. - # We inject between every stop to ensure the midpoint does not shift. - if max_delta_e > 0: - # Initial check to see if we need to insert more stops - m_delta = 0 - for i, entry in enumerate(ret): - if i == 0: - continue - m_delta = max(m_delta, entry['color'].delta_e(ret[i - 1]['color'])) - - while m_delta > max_delta_e: - # Inject stops while measuring again to see if it was sufficient - m_delta = 0 - i = 1 - while i < len(ret) and len(ret) < max_steps: - prev = ret[i - 1] - cur = ret[i] - p = (cur['p'] + prev['p']) / 2 - color = interpolator(p) - m_delta = max(m_delta, color.delta_e(prev['color']), color.delta_e(cur['color'])) - ret.insert(i, {'p': p, 'color': color}) - i += 2 - - return [i['color'] for i in ret] + return self.interpolate(color, **interpolate_args).steps(steps, max_steps, max_delta_e) def mix(self, color, percent=util.DEF_MIX, *, in_place=False, **interpolate_args): """ diff --git a/lib/coloraide/spaces/__init__.py b/lib/coloraide/spaces/__init__.py index 247a20be..6c62d376 100644 --- a/lib/coloraide/spaces/__init__.py +++ b/lib/coloraide/spaces/__init__.py @@ -2,7 +2,6 @@ from abc import ABCMeta from .. import util from . import _parse -from . import _cat # Technically this form can handle any number of channels as long as any # extra are thrown away. We only support 6 currently. If we ever support @@ -16,6 +15,20 @@ **_parse.COLOR_PARTS ) +WHITES = { + "A": [1.09850, 1.00000, 0.35585], + "B": [0.99072, 1.00000, 0.85223], + "C": [0.98074, 1.00000, 1.18232], + "D50": [0.96422, 1.00000, 0.82521], + "D55": [0.95682, 1.00000, 0.92149], + "D65": [0.95047, 1.00000, 1.08883], + "D75": [0.94972, 1.00000, 1.22638], + "E": [1.00000, 1.00000, 1.00000], + "F2": [0.99186, 1.00000, 0.67393], + "F7": [0.95041, 1.00000, 1.08747], + "F11": [1.00962, 1.00000, 0.64350] +} + class Angle(float): """Angle type.""" @@ -69,7 +82,7 @@ class Space( # space, the values can be greatly out of specification (looking at you HSL). GAMUT_CHECK = None # White point - WHITE = _cat.WHITES["D50"] + WHITE = "D50" def __init__(self, color, alpha=None): """Initialize.""" @@ -135,7 +148,7 @@ def space(cls): def white(cls): """Get the white color for this color space.""" - return cls.WHITE + return WHITES[cls.WHITE] @property def alpha(self): diff --git a/lib/coloraide/spaces/_cat.py b/lib/coloraide/spaces/_cat.py deleted file mode 100644 index 2f73e053..00000000 --- a/lib/coloraide/spaces/_cat.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Chromatic adaption transforms.""" -from .. import util - -white_d65 = [0.95047, 1.00000, 1.08883] -white_d50 = [0.96422, 1.00000, 0.82521] - -WHITES = { - "D50": white_d50, - "D65": white_d65 -} - - -def d50_to_d65(xyz): - """Bradford chromatic adaptation from D50 to D65.""" - - m = [ - [0.9555766150331048, -0.0230393447160789, 0.0631636322498012], - [-0.0282895442435549, 1.0099416173711144, 0.0210076549961903], - [0.0122981657172073, -0.0204830252324494, 1.3299098264497566] - ] - - return util.dot(m, xyz) - - -def d65_to_d50(xyz): - """Bradford chromatic adaptation from D65 to D50.""" - - m = [ - [1.0478112436606313, 0.022886602481693, -0.0501269759685289], - [0.0295423982905749, 0.9904844034904393, -0.0170490956289616], - [-0.0092344897233095, 0.0150436167934987, 0.752131635474606] - ] - - return util.dot(m, xyz) - - -def chromatic_adaption(w1, w2, xyz): - """Chromatic adaption.""" - - if w1 == w2: - return xyz - elif w1 == WHITES["D50"] and w2 == WHITES["D65"]: - return d50_to_d65(xyz) - elif w1 == WHITES["D65"] and w2 == WHITES["D50"]: - return d65_to_d50(xyz) - else: # pragma: no cover - # Should only occur internally if we are doing something wrong. - raise ValueError('Unknown white point encountered: {} -> {}'.format(str(w1), str(w2))) diff --git a/lib/coloraide/spaces/a98_rgb.py b/lib/coloraide/spaces/a98_rgb.py index 99cf0b91..5c6c8f35 100644 --- a/lib/coloraide/spaces/a98_rgb.py +++ b/lib/coloraide/spaces/a98_rgb.py @@ -1,6 +1,5 @@ """A98 RGB color class.""" from ..spaces import RE_DEFAULT_MATCH -from ..spaces import _cat from .srgb import SRGB from .xyz import XYZ from .. import util @@ -18,9 +17,9 @@ def lin_a98rgb_to_xyz(rgb): """ m = [ - [0.5767308871981476, 0.1855539507112141, 0.1881851620906385], - [0.2973768637115448, 0.6273490714522, 0.0752740648362554], - [0.0270342603374131, 0.0706872193185578, 0.9911085203440293] + [0.5767308871981476, 0.18555395071121408, 0.18818516209063846], + [0.2973768637115448, 0.6273490714522, 0.07527406483625539], + [0.027034260337413137, 0.0706872193185578, 0.9911085203440293] ] return util.dot(m, rgb) @@ -30,9 +29,9 @@ def xyz_to_lin_a98rgb(xyz): """Convert XYZ to linear-light a98-rgb.""" m = [ - [2.04136897926008, -0.5649463871751959, -0.3446943843778484], - [-0.9692660305051867, 1.8760108454466937, 0.0415560175303498], - [0.0134473872161703, -0.1183897423541256, 1.0154095719504166] + [2.04136897926008, -0.5649463871751959, -0.34469438437784844], + [-0.9692660305051867, 1.8760108454466937, 0.04155601753034983], + [0.013447387216170269, -0.11838974235412557, 1.0154095719504166] ] return util.dot(m, xyz) @@ -55,16 +54,16 @@ class A98RGB(SRGB): SPACE = "a98-rgb" DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" @classmethod - def _to_xyz(cls, rgb): + def _to_xyz(cls, parent, rgb): """To XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_a98rgb_to_xyz(lin_a98rgb(rgb))) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_a98rgb_to_xyz(lin_a98rgb(rgb))) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return gam_a98rgb(xyz_to_lin_a98rgb(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))) + return gam_a98rgb(xyz_to_lin_a98rgb(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz))) diff --git a/lib/coloraide/spaces/display_p3.py b/lib/coloraide/spaces/display_p3.py index c3491382..d0d7fb33 100644 --- a/lib/coloraide/spaces/display_p3.py +++ b/lib/coloraide/spaces/display_p3.py @@ -1,6 +1,5 @@ """Display-p3 color class.""" from ..spaces import RE_DEFAULT_MATCH -from ..spaces import _cat from .srgb import SRGB, lin_srgb, gam_srgb from .xyz import XYZ from .. import util @@ -28,9 +27,9 @@ def xyz_to_lin_p3(xyz): """Convert XYZ to linear-light P3.""" m = [ - [2.493180755328967, -0.9312655254971399, -0.4026597237588819], - [-0.8295031158210786, 1.7626941211197922, 0.0236250887417396], - [0.0358536257800717, -0.0761889547826522, 0.9570926215180221] + [2.493180755328967, -0.9312655254971399, -0.40265972375888187], + [-0.8295031158210786, 1.7626941211197922, 0.02362508874173957], + [0.035853625780071716, -0.07618895478265224, 0.9570926215180221] ] return util.dot(m, xyz) @@ -53,16 +52,16 @@ class DisplayP3(SRGB): SPACE = "display-p3" DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" @classmethod - def _to_xyz(cls, rgb): + def _to_xyz(cls, parent, rgb): """To XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_p3_to_xyz(lin_p3(rgb))) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_p3_to_xyz(lin_p3(rgb))) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return gam_p3(xyz_to_lin_p3(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))) + return gam_p3(xyz_to_lin_p3(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz))) diff --git a/lib/coloraide/spaces/hsl.py b/lib/coloraide/spaces/hsl.py index 4e57aa0e..673e812a 100644 --- a/lib/coloraide/spaces/hsl.py +++ b/lib/coloraide/spaces/hsl.py @@ -1,6 +1,5 @@ """HSL class.""" from ..spaces import Space, RE_DEFAULT_MATCH, Angle, Percent, GamutBound, Cylindrical -from . import _cat from .srgb import SRGB from .. import util import re @@ -60,7 +59,8 @@ class HSL(Cylindrical, Space): SPACE = "hsl" CHANNEL_NAMES = ("hue", "saturation", "lightness", "alpha") DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" + GAMUT_CHECK = "srgb" RANGE = ( GamutBound([Angle(0.0), Angle(360.0)]), @@ -113,25 +113,25 @@ def null_adjust(cls, coords, alpha): return coords, alpha @classmethod - def _to_srgb(cls, hsl): + def _to_srgb(cls, parent, hsl): """To sRGB.""" return hsl_to_srgb(hsl) @classmethod - def _from_srgb(cls, rgb): + def _from_srgb(cls, parent, rgb): """From sRGB.""" return srgb_to_hsl(rgb) @classmethod - def _to_xyz(cls, hsl): + def _to_xyz(cls, parent, hsl): """To XYZ.""" - return SRGB._to_xyz(cls._to_srgb(hsl)) + return SRGB._to_xyz(parent, cls._to_srgb(parent, hsl)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return cls._from_srgb(SRGB._from_xyz(xyz)) + return cls._from_srgb(parent, SRGB._from_xyz(parent, xyz)) diff --git a/lib/coloraide/spaces/hsv.py b/lib/coloraide/spaces/hsv.py index 0a60441e..ebc2949e 100644 --- a/lib/coloraide/spaces/hsv.py +++ b/lib/coloraide/spaces/hsv.py @@ -1,6 +1,5 @@ """HSV class.""" from ..spaces import Space, RE_DEFAULT_MATCH, Angle, Percent, GamutBound, Cylindrical -from . import _cat from .srgb import SRGB from .hsl import HSL from .. import util @@ -56,8 +55,8 @@ class HSV(Cylindrical, Space): SPACE = "hsv" CHANNEL_NAMES = ("hue", "saturation", "value", "alpha") DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - GAMUT_CHECK = "hsl" - WHITE = _cat.WHITES["D65"] + GAMUT_CHECK = "srgb" + WHITE = "D65" RANGE = ( GamutBound([Angle(0.0), Angle(360.0)]), @@ -110,37 +109,37 @@ def null_adjust(cls, coords, alpha): return coords, alpha @classmethod - def _to_xyz(cls, hsv): + def _to_xyz(cls, parent, hsv): """To XYZ.""" - return SRGB._to_xyz(cls._to_srgb(hsv)) + return SRGB._to_xyz(parent, cls._to_srgb(parent, hsv)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return cls._from_srgb(SRGB._from_xyz(xyz)) + return cls._from_srgb(parent, SRGB._from_xyz(parent, xyz)) @classmethod - def _to_hsl(cls, hsv): + def _to_hsl(cls, parent, hsv): """To HSL.""" return hsv_to_hsl(hsv) @classmethod - def _from_hsl(cls, hsl): + def _from_hsl(cls, parent, hsl): """From HSL.""" return hsl_to_hsv(hsl) @classmethod - def _to_srgb(cls, hsv): + def _to_srgb(cls, parent, hsv): """To sRGB.""" - return HSL._to_srgb(cls._to_hsl(hsv)) + return HSL._to_srgb(parent, cls._to_hsl(parent, hsv)) @classmethod - def _from_srgb(cls, rgb): + def _from_srgb(cls, parent, rgb): """From sRGB.""" - return cls._from_hsl(HSL._from_srgb(rgb)) + return cls._from_hsl(parent, HSL._from_srgb(parent, rgb)) diff --git a/lib/coloraide/spaces/hwb.py b/lib/coloraide/spaces/hwb.py index 5c5f1f94..38ac8e59 100644 --- a/lib/coloraide/spaces/hwb.py +++ b/lib/coloraide/spaces/hwb.py @@ -1,6 +1,5 @@ """HWB class.""" from ..spaces import Space, RE_DEFAULT_MATCH, Angle, Percent, GamutBound, Cylindrical -from . import _cat from .srgb import SRGB from .hsv import HSV from .. import util @@ -43,8 +42,8 @@ class HWB(Cylindrical, Space): SPACE = "hwb" CHANNEL_NAMES = ("hue", "whiteness", "blackness", "alpha") DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - GAMUT_CHECK = "hsl" - WHITE = _cat.WHITES["D65"] + GAMUT_CHECK = "srgb" + WHITE = "D65" RANGE = ( GamutBound([Angle(0.0), Angle(360.0)]), @@ -97,49 +96,49 @@ def null_adjust(cls, coords, alpha): return coords, alpha @classmethod - def _to_xyz(cls, hwb): + def _to_xyz(cls, parent, hwb): """SRGB to XYZ.""" - return SRGB._to_xyz(cls._to_srgb(hwb)) + return SRGB._to_xyz(parent, cls._to_srgb(parent, hwb)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """XYZ to SRGB.""" - return cls._from_srgb(SRGB._from_xyz(xyz)) + return cls._from_srgb(parent, SRGB._from_xyz(parent, xyz)) @classmethod - def _to_srgb(cls, hwb): + def _to_srgb(cls, parent, hwb): """To sRGB.""" - return HSV._to_srgb(cls._to_hsv(hwb)) + return HSV._to_srgb(parent, cls._to_hsv(parent, hwb)) @classmethod - def _from_srgb(cls, srgb): + def _from_srgb(cls, parent, srgb): """From sRGB.""" - return cls._from_hsv(HSV._from_srgb(srgb)) + return cls._from_hsv(parent, HSV._from_srgb(parent, srgb)) @classmethod - def _to_hsl(cls, hwb): + def _to_hsl(cls, parent, hwb): """To HSL.""" - return HSV._to_hsl(hwb_to_hsv(hwb)) + return HSV._to_hsl(parent, hwb_to_hsv(hwb)) @classmethod - def _from_hsl(cls, hsl): + def _from_hsl(cls, parent, hsl): """From HSL.""" - return hsv_to_hwb(HSV._from_hsl(hsl)) + return hsv_to_hwb(HSV._from_hsl(parent, hsl)) @classmethod - def _to_hsv(cls, hwb): + def _to_hsv(cls, parent, hwb): """To HSV.""" return hwb_to_hsv(hwb) @classmethod - def _from_hsv(cls, hsv): + def _from_hsv(cls, parent, hsv): """From HSV.""" return hsv_to_hwb(hsv) diff --git a/lib/coloraide/spaces/ictcp.py b/lib/coloraide/spaces/ictcp.py index bad85410..aeb71f34 100644 --- a/lib/coloraide/spaces/ictcp.py +++ b/lib/coloraide/spaces/ictcp.py @@ -4,7 +4,6 @@ https://professional.dolby.com/siteassets/pdfs/ictcp_dolbywhitepaper_v071.pdf """ from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound -from . import _cat from .xyz_d65 import XYZ from .. import util import re @@ -87,7 +86,7 @@ class ICtCp(Space): SPACE = "ictcp" CHANNEL_NAMES = ("i", "ct", "cp", "alpha") DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" RANGE = ( GamutUnbound([0, 1]), @@ -132,13 +131,13 @@ def cp(self, value): self._coords[2] = self._handle_input(value) @classmethod - def _to_xyz(cls, ictcp): + def _to_xyz(cls, parent, ictcp): """To XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), ictcp_to_xyz_d65(ictcp)) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, ictcp_to_xyz_d65(ictcp)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return xyz_d65_to_ictcp(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)) + return xyz_d65_to_ictcp(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz)) diff --git a/lib/coloraide/spaces/jzazbz.py b/lib/coloraide/spaces/jzazbz.py index 31fc40ff..5f828067 100644 --- a/lib/coloraide/spaces/jzazbz.py +++ b/lib/coloraide/spaces/jzazbz.py @@ -4,7 +4,6 @@ https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272 """ from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound -from . import _cat from .xyz_d65 import XYZ from .. import util import re @@ -31,21 +30,21 @@ # XYZ transform matrices xyz_to_lms_m = [ - [0.41478972, 0.579999, 0.0146480], - [-0.2015100, 1.120649, 0.0531008], - [-0.0166008, 0.264800, 0.6684799] + [0.41478972, 0.579999, 0.014648], + [-0.20151, 1.120649, 0.0531008], + [-0.0166008, 0.2648, 0.6684799] ] lms_to_xyz_mi = [ - [1.9242264357876069, -1.0047923125953657, 0.037651404030618], - [0.3503167620949991, 0.7264811939316552, -0.065384422948085], - [-0.0909828109828475, -0.3127282905230739, 1.5227665613052603] + [1.9242264357876069, -1.0047923125953657, 0.037651404030617994], + [0.35031676209499907, 0.7264811939316552, -0.06538442294808501], + [-0.09098281098284754, -0.3127282905230739, 1.5227665613052603] ] # LMS to Izazbz matrices lms_p_to_izazbz_m = [ [0.5, 0.5, 0], - [3.524000, -4.066708, 0.542708], + [3.524, -4.066708, 0.542708], [0.199076, 1.096799, -1.295875] ] @@ -107,7 +106,7 @@ class Jzazbz(Space): SPACE = "jzazbz" CHANNEL_NAMES = ("jz", "az", "bz", "alpha") DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" RANGE = ( GamutUnbound([0, 1]), @@ -152,13 +151,13 @@ def bz(self, value): self._coords[2] = self._handle_input(value) @classmethod - def _to_xyz(cls, jzazbz): + def _to_xyz(cls, parent, jzazbz): """To XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), jzazbz_to_xyz_d65(jzazbz)) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, jzazbz_to_xyz_d65(jzazbz)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return xyz_d65_to_jzazbz(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)) + return xyz_d65_to_jzazbz(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz)) diff --git a/lib/coloraide/spaces/jzczhz.py b/lib/coloraide/spaces/jzczhz.py index 6967d299..18e9bcfb 100644 --- a/lib/coloraide/spaces/jzczhz.py +++ b/lib/coloraide/spaces/jzczhz.py @@ -4,7 +4,6 @@ https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272 """ from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle -from . import _cat from .jzazbz import Jzazbz from .. import util import re @@ -57,7 +56,7 @@ class JzCzhz(Cylindrical, Space): SPACE = "jzczhz" CHANNEL_NAMES = ("jz", "chroma", "hue", "alpha") DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" RANGE = ( GamutUnbound([0.0, 1.0]), @@ -110,25 +109,25 @@ def null_adjust(cls, coords, alpha): return coords, alpha @classmethod - def _to_jzazbz(cls, jzczhz): + def _to_jzazbz(cls, parent, jzczhz): """To Jzazbz.""" return jzczhz_to_jzazbz(jzczhz) @classmethod - def _from_jzazbz(cls, jzazbz): + def _from_jzazbz(cls, parent, jzazbz): """From Jzazbz.""" return jzazbz_to_jzczhz(jzazbz) @classmethod - def _to_xyz(cls, jzczhz): + def _to_xyz(cls, parent, jzczhz): """To XYZ.""" - return Jzazbz._to_xyz(cls._to_jzazbz(jzczhz)) + return Jzazbz._to_xyz(parent, cls._to_jzazbz(parent, jzczhz)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return cls._from_jzazbz(Jzazbz._from_xyz(xyz)) + return cls._from_jzazbz(parent, Jzazbz._from_xyz(parent, xyz)) diff --git a/lib/coloraide/spaces/lab.py b/lib/coloraide/spaces/lab.py index fac9a915..8231c0e3 100644 --- a/lib/coloraide/spaces/lab.py +++ b/lib/coloraide/spaces/lab.py @@ -1,6 +1,5 @@ """Lab class.""" from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Percent -from . import _cat from .xyz import XYZ from .. import util import re @@ -12,7 +11,7 @@ RATIO3 = 841 / 108 -def lab_to_xyz(lab): +def lab_to_xyz(lab, white): """ Convert Lab to D50-adapted XYZ. @@ -34,14 +33,14 @@ def lab_to_xyz(lab): ] # Compute XYZ by scaling `xyz` by reference `white` - return util.multiply(xyz, Lab.white()) + return util.multiply(xyz, white) -def xyz_to_lab(xyz): +def xyz_to_lab(xyz, white): """Assuming XYZ is relative to D50, convert to CIE Lab from CIE standard.""" # compute `xyz`, which is XYZ scaled relative to reference white - xyz = util.divide(xyz, Lab.white()) + xyz = util.divide(xyz, white) # Compute `fx`, `fy`, and `fz` fx, fy, fz = [util.cbrt(i) if i > EPSILON3 else (RATIO3 * i) + RATIO1 for i in xyz] @@ -105,16 +104,16 @@ class Lab(LabBase): SPACE = "lab" DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D50"] + WHITE = "D50" @classmethod - def _to_xyz(cls, lab): + def _to_xyz(cls, parent, lab): """To XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), lab_to_xyz(lab)) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lab_to_xyz(lab, cls.white())) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return xyz_to_lab(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)) + return xyz_to_lab(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz), cls.white()) diff --git a/lib/coloraide/spaces/lab_d65.py b/lib/coloraide/spaces/lab_d65.py index 6ef1fefa..92ac2b20 100644 --- a/lib/coloraide/spaces/lab_d65.py +++ b/lib/coloraide/spaces/lab_d65.py @@ -1,6 +1,5 @@ """Lab D65 class.""" from ..spaces import RE_DEFAULT_MATCH -from . import _cat from .xyz import XYZ from .lab import LabBase, lab_to_xyz, xyz_to_lab import re @@ -11,16 +10,16 @@ class LabD65(LabBase): SPACE = "lab-d65" DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" @classmethod - def _to_xyz(cls, lab): + def _to_xyz(cls, parent, lab): """To XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), lab_to_xyz(lab)) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lab_to_xyz(lab, cls.white())) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return xyz_to_lab(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)) + return xyz_to_lab(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz), cls.white()) diff --git a/lib/coloraide/spaces/lch.py b/lib/coloraide/spaces/lch.py index 90aef2a8..91dabae9 100644 --- a/lib/coloraide/spaces/lch.py +++ b/lib/coloraide/spaces/lch.py @@ -1,6 +1,5 @@ """Lch class.""" from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle, Percent -from . import _cat from .lab import Lab from .. import util import re @@ -109,28 +108,28 @@ class Lch(LchBase): SPACE = "lch" DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D50"] + WHITE = "D50" @classmethod - def _to_lab(cls, lch): + def _to_lab(cls, parent, lch): """To Lab.""" return lch_to_lab(lch) @classmethod - def _from_lab(cls, lab): + def _from_lab(cls, parent, lab): """To Lab.""" return lab_to_lch(lab) @classmethod - def _to_xyz(cls, lch): + def _to_xyz(cls, parent, lch): """To XYZ.""" - return Lab._to_xyz(cls._to_lab(lch)) + return Lab._to_xyz(parent, cls._to_lab(parent, lch)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return cls._from_lab(Lab._from_xyz(xyz)) + return cls._from_lab(parent, Lab._from_xyz(parent, xyz)) diff --git a/lib/coloraide/spaces/lch_d65.py b/lib/coloraide/spaces/lch_d65.py index 7fffab07..eeaf9709 100644 --- a/lib/coloraide/spaces/lch_d65.py +++ b/lib/coloraide/spaces/lch_d65.py @@ -1,6 +1,5 @@ """Lch D65 class.""" from ..spaces import RE_DEFAULT_MATCH -from . import _cat from .lab_d65 import LabD65 from .lch import LchBase, lch_to_lab, lab_to_lch import re @@ -11,28 +10,28 @@ class LchD65(LchBase): SPACE = "lch-d65" DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" @classmethod - def _to_lab_d65(cls, lchd65): + def _to_lab_d65(cls, parent, lchd65): """To Lab.""" return lch_to_lab(lchd65) @classmethod - def _from_lab_d65(cls, labd65): + def _from_lab_d65(cls, parent, labd65): """To Lab.""" return lab_to_lch(labd65) @classmethod - def _to_xyz(cls, lch): + def _to_xyz(cls, parent, lch): """To XYZ.""" - return LabD65._to_xyz(cls._to_lab_d65(lch)) + return LabD65._to_xyz(parent, cls._to_lab_d65(parent, lch)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return cls._from_lab_d65(LabD65._from_xyz(xyz)) + return cls._from_lab_d65(parent, LabD65._from_xyz(parent, xyz)) diff --git a/lib/coloraide/spaces/oklab.py b/lib/coloraide/spaces/oklab.py index 83730b34..61991d7e 100644 --- a/lib/coloraide/spaces/oklab.py +++ b/lib/coloraide/spaces/oklab.py @@ -4,7 +4,6 @@ https://bottosson.github.io/posts/oklab/ """ from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound -from . import _cat from .xyz import XYZ from .. import util import re @@ -12,25 +11,25 @@ m1 = [ [0.8189330101, 0.0329845436, 0.0482003018], [0.3618667424, 0.9293118715, 0.2643662691], - [-0.1288597137, 0.0361456387, 0.6338517070] + [-0.1288597137, 0.0361456387, 0.633851707] ] m2 = [ [0.2104542553, 1.9779984951, 0.0259040371], - [0.7936177850, -2.4285922050, 0.7827717662], - [-0.0040720468, 0.4505937099, -0.8086757660] + [0.793617785, -2.428592205, 0.7827717662], + [-0.0040720468, 0.4505937099, -0.808675766] ] m1i = [ - [1.2270138511035211, -0.0405801784232806, -0.0763812845057069], - [-0.5577999806518223, 1.11225686961683, -0.4214819784180127], + [1.2270138511035211, -0.04058017842328059, -0.07638128450570689], + [-0.5577999806518223, 1.11225686961683, -0.42148197841801266], [0.2812561489664678, -0.0716766786656012, 1.5861632204407947] ] m2i = [ [0.9999999984505199, 1.0000000088817607, 1.0000000546724108], - [0.3963377921737679, -0.1055613423236564, -0.0894841820949658], - [0.2158037580607588, -0.0638541747717059, -1.2914855378640917] + [0.3963377921737679, -0.10556134232365635, -0.08948418209496575], + [0.2158037580607588, -0.06385417477170591, -1.2914855378640917] ] @@ -52,7 +51,7 @@ class Oklab(Space): SPACE = "oklab" CHANNEL_NAMES = ("lightness", "a", "b", "alpha") DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" RANGE = ( GamutUnbound([0, 1]), @@ -97,13 +96,13 @@ def b(self, value): self._coords[2] = self._handle_input(value) @classmethod - def _to_xyz(cls, oklab): + def _to_xyz(cls, parent, oklab): """To XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), oklab_to_xyz_d65(oklab)) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, oklab_to_xyz_d65(oklab)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return xyz_d65_to_oklab(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)) + return xyz_d65_to_oklab(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz)) diff --git a/lib/coloraide/spaces/oklch.py b/lib/coloraide/spaces/oklch.py index e6c0821c..c8af4dcd 100644 --- a/lib/coloraide/spaces/oklch.py +++ b/lib/coloraide/spaces/oklch.py @@ -1,6 +1,5 @@ """LCH class.""" from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle -from . import _cat from .oklab import Oklab from .. import util import re @@ -49,7 +48,7 @@ class Oklch(Cylindrical, Space): SPACE = "oklch" CHANNEL_NAMES = ("lightness", "chroma", "hue", "alpha") DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" RANGE = ( GamutUnbound([0.0, 1.0]), @@ -102,25 +101,25 @@ def null_adjust(cls, coords, alpha): return coords, alpha @classmethod - def _to_oklab(cls, oklch): + def _to_oklab(cls, parent, oklch): """To Lab.""" return oklch_to_oklab(oklch) @classmethod - def _from_oklab(cls, oklab): + def _from_oklab(cls, parent, oklab): """To Lab.""" return oklab_to_oklch(oklab) @classmethod - def _to_xyz(cls, oklch): + def _to_xyz(cls, parent, oklch): """To XYZ.""" - return Oklab._to_xyz(cls._to_oklab(oklch)) + return Oklab._to_xyz(parent, cls._to_oklab(parent, oklch)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return cls._from_oklab(Oklab._from_xyz(xyz)) + return cls._from_oklab(parent, Oklab._from_xyz(parent, xyz)) diff --git a/lib/coloraide/spaces/prophoto_rgb.py b/lib/coloraide/spaces/prophoto_rgb.py index eb348665..936427a5 100644 --- a/lib/coloraide/spaces/prophoto_rgb.py +++ b/lib/coloraide/spaces/prophoto_rgb.py @@ -1,6 +1,5 @@ """Pro Photo RGB color class.""" from ..spaces import RE_DEFAULT_MATCH -from . import _cat from .srgb import SRGB from .xyz import XYZ from .. import util @@ -31,8 +30,8 @@ def xyz_to_lin_prophoto(xyz): """Convert XYZ to linear-light prophoto-rgb.""" m = [ - [1.3459433009386652, -0.255607509316767, -0.051111765870885], - [-0.544598869458717, 1.508167317720767, 0.0205351415866469], + [1.3459433009386652, -0.25560750931676696, -0.05111176587088495], + [-0.544598869458717, 1.508167317720767, 0.020535141586646915], [0.0, 0.0, 1.2118127506937628] ] @@ -73,7 +72,7 @@ def gam_prophoto(rgb): if abs(i) < ET: result.append(16.0 * i) else: - result.append(util.npow(i, 1.0 / 1.8)) + result.append(util.nth_root(i, 1.8)) return result @@ -82,16 +81,16 @@ class ProPhotoRGB(SRGB): SPACE = "prophoto-rgb" DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D50"] + WHITE = "D50" @classmethod - def _to_xyz(cls, rgb): + def _to_xyz(cls, parent, rgb): """To XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_prophoto_to_xyz(lin_prophoto(rgb))) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_prophoto_to_xyz(lin_prophoto(rgb))) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return gam_prophoto(xyz_to_lin_prophoto(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))) + return gam_prophoto(xyz_to_lin_prophoto(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz))) diff --git a/lib/coloraide/spaces/rec2020.py b/lib/coloraide/spaces/rec2020.py index 4d7aa2e2..f24e007c 100644 --- a/lib/coloraide/spaces/rec2020.py +++ b/lib/coloraide/spaces/rec2020.py @@ -1,6 +1,5 @@ """Rec 2020 color class.""" from ..spaces import RE_DEFAULT_MATCH -from . import _cat from .srgb import SRGB from .xyz import XYZ from .. import util @@ -26,7 +25,7 @@ def lin_2020(rgb): if abs_i < BETA45: result.append(i / 4.5) else: - result.append(math.copysign(((abs_i + ALPHA - 1) / ALPHA) ** (1 / 0.45), i)) + result.append(math.copysign(util.nth_root((abs_i + ALPHA - 1) / ALPHA, 0.45), i)) return result @@ -69,9 +68,9 @@ def xyz_to_lin_2020(xyz): """Convert XYZ to linear-light rec-2020.""" m = [ - [1.7165106697619734, -0.3556416699867159, -0.2533455418219072], + [1.7165106697619734, -0.35564166998671587, -0.25334554182190716], [-0.6666930011826241, 1.6165022083469103, 0.015768750389995], - [0.017643638767459, -0.0427797816690446, 0.9423050727200183] + [0.017643638767459002, -0.04277978166904461, 0.9423050727200183] ] return util.dot(m, xyz) @@ -82,16 +81,16 @@ class Rec2020(SRGB): SPACE = "rec2020" DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" @classmethod - def _to_xyz(cls, rgb): + def _to_xyz(cls, parent, rgb): """To XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_2020_to_xyz(lin_2020(rgb))) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_2020_to_xyz(lin_2020(rgb))) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return gam_2020(xyz_to_lin_2020(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))) + return gam_2020(xyz_to_lin_2020(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz))) diff --git a/lib/coloraide/spaces/srgb.py b/lib/coloraide/spaces/srgb.py index bebb2f82..f19edc50 100644 --- a/lib/coloraide/spaces/srgb.py +++ b/lib/coloraide/spaces/srgb.py @@ -1,6 +1,5 @@ """SRGB color class.""" from ..spaces import RE_DEFAULT_MATCH, Space, GamutBound -from . import _cat from .xyz import XYZ from .. import util import re @@ -15,9 +14,9 @@ def lin_srgb_to_xyz(rgb): """ m = [ - [0.4124564390896923, 0.357576077643909, 0.180437483266399], - [0.2126728514056226, 0.715152155287818, 0.0721749933065596], - [0.0193338955823293, 0.119192025881303, 0.950304078536368] + [0.41245643908969226, 0.357576077643909, 0.18043748326639897], + [0.21267285140562256, 0.715152155287818, 0.07217499330655959], + [0.019333895582329303, 0.11919202588130297, 0.950304078536368] ] return util.dot(m, rgb) @@ -27,9 +26,9 @@ def xyz_to_lin_srgb(xyz): """Convert XYZ to linear-light sRGB.""" m = [ - [3.2404541621141045, -1.5371385127977162, -0.498531409556016], - [-0.969266030505187, 1.8760108454466944, 0.0415560175303498], - [0.0556434309591147, -0.2040259135167538, 1.057225188223179] + [3.2404541621141045, -1.5371385127977162, -0.49853140955601605], + [-0.969266030505187, 1.8760108454466944, 0.04155601753034984], + [0.05564343095911475, -0.20402591351675384, 1.057225188223179] ] return util.dot(m, xyz) @@ -65,7 +64,7 @@ def gam_srgb(rgb): # Mirror linear nature of algorithm on the negative axis abs_i = abs(i) if abs_i > 0.0031308: - result.append(math.copysign(1.055 * (abs_i ** (1 / 2.4)) - 0.055, i)) + result.append(math.copysign(1.055 * (util.nth_root(abs_i, 2.4)) - 0.055, i)) else: result.append(12.92 * i) return result @@ -78,10 +77,9 @@ class SRGB(Space): # In addition to the current gamut, check HSL as it is much more sensitive to small # gamut changes. This is mainly for a better user experience. Colors will still be # mapped/clipped in the current space, unless specified otherwise. - GAMUT_CHECK = "hsl" DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) CHANNEL_NAMES = ("red", "green", "blue", "alpha") - WHITE = _cat.WHITES["D65"] + WHITE = "D65" RANGE = ( GamutBound([0.0, 1.0]), @@ -126,13 +124,13 @@ def blue(self, value): self._coords[2] = self._handle_input(value) @classmethod - def _to_xyz(cls, rgb): + def _to_xyz(cls, parent, rgb): """SRGB to XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_srgb_to_xyz(lin_srgb(rgb))) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_srgb_to_xyz(lin_srgb(rgb))) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """XYZ to SRGB.""" - return gam_srgb(xyz_to_lin_srgb(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz))) + return gam_srgb(xyz_to_lin_srgb(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz))) diff --git a/lib/coloraide/spaces/srgb_linear.py b/lib/coloraide/spaces/srgb_linear.py index 1c5824e5..bea5ec8a 100644 --- a/lib/coloraide/spaces/srgb_linear.py +++ b/lib/coloraide/spaces/srgb_linear.py @@ -1,6 +1,5 @@ """SRGB Linear color class.""" from ..spaces import RE_DEFAULT_MATCH -from . import _cat from .srgb import SRGB, lin_srgb_to_xyz, xyz_to_lin_srgb, lin_srgb, gam_srgb from .xyz import XYZ import re @@ -11,28 +10,28 @@ class SRGBLinear(SRGB): SPACE = "srgb-linear" DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" @classmethod - def _to_srgb(cls, rgb): + def _to_srgb(cls, parent, rgb): """Linear sRGB to sRGB.""" return gam_srgb(rgb) @classmethod - def _from_srgb(cls, rgb): + def _from_srgb(cls, parent, rgb): """sRGB to linear sRGB.""" return lin_srgb(rgb) @classmethod - def _to_xyz(cls, rgb): + def _to_xyz(cls, parent, rgb): """SRGB Linear to XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), lin_srgb_to_xyz(rgb)) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, lin_srgb_to_xyz(rgb)) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """XYZ to SRGB Linear.""" - return xyz_to_lin_srgb(_cat.chromatic_adaption(XYZ.white(), cls.white(), xyz)) + return xyz_to_lin_srgb(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz)) diff --git a/lib/coloraide/spaces/xyz.py b/lib/coloraide/spaces/xyz.py index cbdff295..1aadd569 100644 --- a/lib/coloraide/spaces/xyz.py +++ b/lib/coloraide/spaces/xyz.py @@ -1,6 +1,5 @@ """XYZ class.""" from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound -from . import _cat import re @@ -10,7 +9,7 @@ class XYZ(Space): SPACE = "xyz" CHANNEL_NAMES = ("x", "y", "z", "alpha") DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D50"] + WHITE = "D50" RANGE = ( GamutUnbound([0.0, 1.0]), diff --git a/lib/coloraide/spaces/xyz_d65.py b/lib/coloraide/spaces/xyz_d65.py index c3b27c5b..eebcff16 100644 --- a/lib/coloraide/spaces/xyz_d65.py +++ b/lib/coloraide/spaces/xyz_d65.py @@ -1,6 +1,5 @@ """XYZ D65 class.""" from ..spaces import RE_DEFAULT_MATCH, GamutUnbound -from . import _cat from .xyz import XYZ import re @@ -11,7 +10,7 @@ class XYZD65(XYZ): SPACE = "xyz-d65" CHANNEL_NAMES = ("x", "y", "z", "alpha") DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) - WHITE = _cat.WHITES["D65"] + WHITE = "D65" RANGE = ( GamutUnbound([0.0, 1.0]), @@ -20,13 +19,13 @@ class XYZD65(XYZ): ) @classmethod - def _to_xyz(cls, xyzd65): + def _to_xyz(cls, parent, xyzd65): """To XYZ.""" - return _cat.chromatic_adaption(cls.white(), XYZ.white(), xyzd65) + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, xyzd65) @classmethod - def _from_xyz(cls, xyz): + def _from_xyz(cls, parent, xyz): """From XYZ.""" - return _cat.chromatic_adaption(XYZ.white(), cls.white(), xyz) + return parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz) diff --git a/lib/coloraide/util.py b/lib/coloraide/util.py index 84e1806a..1ddd33f5 100644 --- a/lib/coloraide/util.py +++ b/lib/coloraide/util.py @@ -1,12 +1,10 @@ """Utilities.""" -import decimal +import copy import math import numbers -import re import warnings from functools import wraps -RE_FLOAT_TRIM = re.compile(r'^(?P-?\d+)(?P\.0+|(?P\.\d*[1-9])0+)$') NaN = float('nan') INF = float('inf') ACHROMATIC_THRESHOLD = 0.0005 @@ -97,7 +95,7 @@ def is_number(value): def is_nan(value): - """Print is "not a number".""" + """Check if value is "not a number".""" return math.isnan(value) @@ -221,12 +219,131 @@ def divide(a, b): return value -def cbrt(x): - """Cube root.""" +def diag(v, k=0): + """Create a diagonal matrix from a vector or return a vector of the diagonal of a matrix.""" - if 0 <= x: - return x ** (1.0 / 3.0) - return -(-x) ** (1.0 / 3.0) + is_vector = isinstance(v[0], numbers.Number) + size = len(v) + d = [] + + if is_vector: + # Create a diagonal matrix with the provided values + for i, value in enumerate(v): + # Check that the matrix is square, we .cannot invert the matrix if it is not + d.append([0] * i + [value] + [0] * (size - i - 1)) + else: # pragma: no cover + for r in v: + if len(r) != size: + raise ValueError('Matrix must be a n x n matrix') + if 0 <= k < size: + d.append(r[k]) + k += 1 + return d + + +def inv(matrix): + """ + Invert the matrix. + + Derived from https://github.com/ThomIves/MatrixInverse. + + While not as performant as using `numpy`, we are often caching any + inversion we are doing, so this keeps us from having to require all + of `numpy` for the few hits to this we do. + + This is free and unencumbered software released into the public domain. + + Anyone is free to copy, modify, publish, use, compile, sell, or + distribute this software, either in source code form or as a compiled + binary, for any purpose, commercial or non-commercial, and by any + means. + + In jurisdictions that recognize copyright laws, the author or authors + of this software dedicate any and all copyright interest in the + software to the public domain. We make this dedication for the benefit + of the public at large and to the detriment of our heirs and + successors. We intend this dedication to be an overt act of + relinquishment in perpetuity of all present and future rights to this + software under copyright law. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR + OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + For more information, please refer to + """ + + size = len(matrix) + indices = list(range(size)) + m = copy.deepcopy(matrix) + + # Ensure we have a square matrix + for r in m: + if len(r) != size: # pragma: no cover + raise ValueError('Matrix must be a n x n matrix') + + # Create an identity matrix of the same size as our provided matrix + im = diag([1] * size) + + # Iterating through each row, we will scale each row by it's "focus diagonal". + # Then using the scaled row, we will adjust the other rows. + # ``` + # [[fd, 0, 0 ] + # [0, fd, 0 ] + # [0, 0, fd]] + # ``` + for fd in indices: + # We will divide each value in the row by the "focus diagonal" value. + # If the we have a zero for the given `fd` value, we cannot invert. + denom = m[fd][fd] + if denom == 0: # pragma: no cover + raise ValueError('Matrix is not invertable') + + # We are converting the matrix to the identity and vice versa, + # So scale the diagonal such that it will now equal 1. + # Additionally, the same operations will be applied to the identity matrix + # and will turn it into `m ** -1` (what we are looking for) + fd_scalar = 1.0 / denom + for j in indices: + m[fd][j] *= fd_scalar + im[fd][j] *= fd_scalar + + # Now, using the value found at the index `fd` in the remaining rows (excluding `row[fd]`), + # Where `cr` is the current row under evaluation, subtract `row[cr][fd] * row[fd] from row[cr]`. + for cr in indices[0:fd] + indices[fd + 1:]: + # The scalar for the current row + cr_scalar = m[cr][fd] + + # Scale each item in the `row[fd]` and subtract it from the current row `row[cr]` + for j in indices: + m[cr][j] -= cr_scalar * m[fd][j] + im[cr][j] -= cr_scalar * im[fd][j] + + # The identify matrix is now the inverse matrix and vice versa. + return im + + +def cbrt(n): + """Calculate cube root.""" + + return nth_root(n, 3) + + +def nth_root(n, p): + """Calculate nth root.""" + + if p == 0: # pragma: no cover + return float('inf') + + if n == 0: + # Can't do anything with zero + return 0 + + return math.copysign(abs(n) ** (p ** -1), n) def clamp(value, mn=None, mx=None): @@ -242,34 +359,6 @@ def clamp(value, mn=None, mx=None): return max(min(value, mx), mn) -def adjust_precision(f, p): - """Adjust precision and scale.""" - - with decimal.localcontext() as ctx: - if p > 0: - # Set precision - ctx.prec = p - ctx.rounding = decimal.ROUND_HALF_UP - - if p == -1: - # Full precision - value = decimal.Decimal(f) - elif p == 0: - # Just round to integer - value = decimal.Decimal(round_half_up(f)) - else: - # Round to precision - value = (decimal.Decimal(f) * decimal.Decimal('1.0')) - exp = value.as_tuple().exponent - if exp < 0 and abs(value.as_tuple().exponent) > p: - value = value.quantize(decimal.Decimal(10) ** -p) - - if value.is_zero(): - value = abs(value) - - return float(value) - - def fmt_float(f, p=0): """ Set float precision and trim precision zeros. @@ -281,20 +370,27 @@ def fmt_float(f, p=0): value = adjust_precision(f, p) string = ('{{:{}f}}'.format('.53' if p == -1 else '.' + str(p))).format(value) - m = RE_FLOAT_TRIM.match(string) - if m: - string = m.group('keep') - if m.group('keep2'): - string += m.group('keep2') - return string + return string if value.is_integer() and p == 0 else string.rstrip('0').rstrip('.') + + +def adjust_precision(f, p=0): + """Adjust precision.""" + + if p == -1: + return f + + elif p == 0: + return round_half_up(f) + + 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) def round_half_up(n, scale=0): """Round half up.""" - if scale == -1: - return n - mult = 10 ** scale return math.floor(n * mult + 0.5) / mult diff --git a/tox.ini b/tox.ini index e009af9b..261afc3d 100644 --- a/tox.ini +++ b/tox.ini @@ -27,6 +27,6 @@ commands= flake8 "{toxinidir}" [flake8] -ignore=D202,D203,D401,W504,E741 +ignore=D202,D203,D401,W504,E741,N818 max-line-length=120 exclude=site/*.py,.tox/*,lib/coloraide/* From b0a0b20460095f0517e307dfa083f166b88fdc0c Mon Sep 17 00:00:00 2001 From: Isaac Muse Date: Thu, 19 Aug 2021 11:25:43 -0600 Subject: [PATCH 02/12] Fix issue with duplicate previews in clone view (#189) Fixes #187 --- CHANGES.md | 1 + ch_preview.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 3ed5a75c..60cee7ca 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,7 @@ 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 diff --git a/ch_preview.py b/ch_preview.py index 5bb40cdb..1b7c89c7 100644 --- a/ch_preview.py +++ b/ch_preview.py @@ -103,7 +103,7 @@ def on_navigate(self, href): """Handle color box click.""" self.view.sel().clear() - for k, v in self.previews[self.view.id()].items(): + for k, v in self.previews[self.view.buffer_id()].items(): if href == v.uid: phantom = self.view.query_phantom(v.pid) if phantom: @@ -231,7 +231,7 @@ def source_iter(self, visible_region, bounds): def get_color_class(self, pt, classes): """Get color class based on selection scope.""" - view_id = self.view.id() + view_id = self.view.buffer_id() if not self.color_classes[view_id] or self.view.settings().get('color_helper.refresh', True): util.debug("Clear color class stash") self.view.settings().set('color_helper.refresh', False) @@ -283,7 +283,7 @@ def do_search(self, force=False): settings = self.view.settings() colors = [] - view_id = self.view.id() + view_id = self.view.buffer_id() # Allow per view scan override option = settings.get("color_helper.scan_override", None) @@ -474,7 +474,7 @@ def do_search(self, force=False): def add_phantoms(self, colors): """Add phantoms.""" - i = self.view.id() + i = self.view.buffer_id() for html, pt, start, end, unique_id in colors: pid = self.view.add_phantom( 'color_helper', @@ -487,21 +487,21 @@ def add_phantoms(self, colors): def reset_previous(self): """Reset previous region.""" - self.previous_region[self.view.id()] = sublime.Region(0) + self.previous_region[self.view.buffer_id()] = sublime.Region(0) def erase_phantoms(self): """Erase phantoms.""" # Obliterate! self.view.erase_phantoms('color_helper') - self.previews[self.view.id()].clear() + self.previews[self.view.buffer_id()].clear() self.reset_previous() def run(self, clear=False, force=False): """Run.""" self.view = self.window.active_view() - ids = set([view.id() for view in self.window.views()]) + ids = set([view.buffer_id() for view in self.window.views()]) keys = set(self.previews.keys()) diff = keys - ids @@ -510,7 +510,7 @@ def run(self, clear=False, force=False): del self.previous_region[i] del self.color_classes[i] - i = self.view.id() + i = self.view.buffer_id() if i not in self.previews: self.previews[i] = {} if i not in self.previous_region: From 82d3d8a0f87f7e25481f038520105c8d0ac65084 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 30 Aug 2021 09:13:45 -0600 Subject: [PATCH 03/12] Update release message --- messages.json | 2 +- messages/recent.md | 32 +++++++++++++++++--------------- mkdocs.yml | 1 + 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/messages.json b/messages.json index c7a1bdf4..b7fd79da 100644 --- a/messages.json +++ b/messages.json @@ -1,4 +1,4 @@ { "install": "messages/install.md", - "3.4.0": "messages/recent.md" + "3.5.0": "messages/recent.md" } diff --git a/messages/recent.md b/messages/recent.md index fe597390..025b48b9 100644 --- a/messages/recent.md +++ b/messages/recent.md @@ -1,4 +1,4 @@ -# ColorHelper 3.3.0 +# ColorHelper 3.5.0 New release! @@ -7,20 +7,22 @@ prior releases. Restart of Sublime Text may be required. -## 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. +## 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. ## Updated from 2.0 to 3.0? diff --git a/mkdocs.yml b/mkdocs.yml index 6a14bbeb..f1c00f89 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,6 +21,7 @@ theme: code: Roboto Mono features: - navigation.tabs + - navigation.top nav: - Getting Started: From 84d9ac5d15612dbac89be4603b2d98b5ed504f80 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sun, 5 Sep 2021 15:42:39 -0600 Subject: [PATCH 04/12] Upgrade to coloraide a24 --- ch_picker.py | 2 +- ch_util.py | 7 +- color_helper.sublime-settings | 2 +- custom/ahex.py | 2 +- custom/hex_0x.py | 2 +- custom/tmtheme.py | 2 +- lib/coloraide/__init__.py | 2 +- lib/coloraide/__meta__.py | 2 +- lib/coloraide/color/__init__.py | 14 +- lib/coloraide/color/gamut/lch_chroma.py | 2 +- lib/coloraide/color/interpolate.py | 11 +- lib/coloraide/css/__init__.py | 17 --- lib/coloraide/css/spaces/__init__.py | 1 - lib/coloraide/spaces/__init__.py | 31 ++++- lib/coloraide/spaces/a98_rgb.py | 4 +- lib/coloraide/spaces/display_p3.py | 4 +- lib/coloraide/spaces/hsl/__init__.py | 1 + lib/coloraide/spaces/{hsl.py => hsl/base.py} | 9 +- .../{css/spaces/hsl.py => spaces/hsl/css.py} | 4 +- lib/coloraide/spaces/hsv.py | 7 +- lib/coloraide/spaces/hwb/__init__.py | 1 + lib/coloraide/spaces/{hwb.py => hwb/base.py} | 11 +- .../{css/spaces/hwb.py => spaces/hwb/css.py} | 4 +- lib/coloraide/spaces/ictcp.py | 9 +- lib/coloraide/spaces/jzazbz.py | 9 +- lib/coloraide/spaces/jzczhz.py | 7 +- lib/coloraide/spaces/lab/__init__.py | 1 + lib/coloraide/spaces/{lab.py => lab/base.py} | 40 +++--- .../{css/spaces/lab.py => spaces/lab/css.py} | 4 +- lib/coloraide/spaces/lab_d65.py | 5 +- lib/coloraide/spaces/lch/__init__.py | 1 + lib/coloraide/spaces/{lch.py => lch/base.py} | 9 +- .../{css/spaces/lch.py => spaces/lch/css.py} | 4 +- lib/coloraide/spaces/lch_d65.py | 5 +- lib/coloraide/spaces/lchuv.py | 126 ++++++++++++++++++ lib/coloraide/spaces/luv.py | 126 ++++++++++++++++++ lib/coloraide/spaces/oklab.py | 7 +- lib/coloraide/spaces/oklch.py | 7 +- lib/coloraide/spaces/prophoto_rgb.py | 4 +- lib/coloraide/spaces/rec2020.py | 4 +- lib/coloraide/spaces/srgb/__init__.py | 1 + .../spaces/{srgb.py => srgb/base.py} | 14 +- .../srgb/color_names.py} | 0 .../spaces/srgb.py => spaces/srgb/css.py} | 12 +- lib/coloraide/spaces/srgb_linear.py | 5 +- lib/coloraide/spaces/xyz.py | 3 +- lib/coloraide/spaces/xyz_d65.py | 3 +- 47 files changed, 417 insertions(+), 131 deletions(-) delete mode 100644 lib/coloraide/css/__init__.py delete mode 100644 lib/coloraide/css/spaces/__init__.py create mode 100644 lib/coloraide/spaces/hsl/__init__.py rename lib/coloraide/spaces/{hsl.py => hsl/base.py} (93%) rename lib/coloraide/{css/spaces/hsl.py => spaces/hsl/css.py} (98%) create mode 100644 lib/coloraide/spaces/hwb/__init__.py rename lib/coloraide/spaces/{hwb.py => hwb/base.py} (92%) rename lib/coloraide/{css/spaces/hwb.py => spaces/hwb/css.py} (98%) create mode 100644 lib/coloraide/spaces/lab/__init__.py rename lib/coloraide/spaces/{lab.py => lab/base.py} (67%) rename lib/coloraide/{css/spaces/lab.py => spaces/lab/css.py} (98%) create mode 100644 lib/coloraide/spaces/lch/__init__.py rename lib/coloraide/spaces/{lch.py => lch/base.py} (93%) rename lib/coloraide/{css/spaces/lch.py => spaces/lch/css.py} (98%) create mode 100644 lib/coloraide/spaces/lchuv.py create mode 100644 lib/coloraide/spaces/luv.py create mode 100644 lib/coloraide/spaces/srgb/__init__.py rename lib/coloraide/spaces/{srgb.py => srgb/base.py} (90%) rename lib/coloraide/{css/spaces/_color_names.py => spaces/srgb/color_names.py} (100%) rename lib/coloraide/{css/spaces/srgb.py => spaces/srgb/css.py} (96%) diff --git a/ch_picker.py b/ch_picker.py index 18f20778..b96f85c1 100644 --- a/ch_picker.py +++ b/ch_picker.py @@ -10,7 +10,7 @@ from mdpopups import colorbox from .lib.coloraide import Color from .lib.coloraide import util as cutil -from .lib.coloraide.css.spaces import _color_names as css_names +from .lib.coloraide.spaces.srgb import color_names as css_names from . import ch_util as util from .ch_mixin import _ColorMixin from .ch_util import DEFAULT, COLOR_FULL_PREC, HEX, HEX_NA diff --git a/ch_util.py b/ch_util.py index fe17fa08..9e3ae36b 100644 --- a/ch_util.py +++ b/ch_util.py @@ -17,11 +17,12 @@ PALETTE_CONFIG = 'color_helper.palettes' REQUIRED_COLOR_VERSION = (0, 1, 0, 'alpha', 19) -UPDATE_COLORS = re.compile(RE_DEFAULT_MATCH.format(**{'color_space': r'[-a-z0-9]+'})) +UPDATE_COLORS = re.compile(RE_DEFAULT_MATCH.format(**{'color_space': r'[-a-z0-9]+', 'channels': 3})) COLOR_FMT_1_0 = (0, 1, 0, 'alpha', 19) PALETTE_FMT = (1, 0) -RE_COLOR_START = r"(?i)(?:\b(? EPSILON else (fx - RATIO1) * RATIO2, - fy ** 3 if fy > EPSILON or l > 8 else (fy - RATIO1) * RATIO2, - fz ** 3 if fz > EPSILON else (fz - RATIO1) * RATIO2 + fx ** 3 if fx > EPSILON3 else (116 * fx - 16) / KAPPA, + fy ** 3 if l > KE else l / KAPPA, + fz ** 3 if fz > EPSILON3 else (116 * fz - 16) / KAPPA ] # Compute XYZ by scaling `xyz` by reference `white` @@ -37,12 +39,19 @@ def lab_to_xyz(lab, white): def xyz_to_lab(xyz, white): - """Assuming XYZ is relative to D50, convert to CIE Lab from CIE standard.""" + """ + Assuming XYZ is relative to D50, convert to CIE Lab 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/ + """ # compute `xyz`, which is XYZ scaled relative to reference white xyz = util.divide(xyz, white) # Compute `fx`, `fy`, and `fz` - fx, fy, fz = [util.cbrt(i) if i > EPSILON3 else (RATIO3 * i) + RATIO1 for i in xyz] + fx, fy, fz = [util.cbrt(i) if i > EPSILON else (KAPPA * i + 16) / 116 for i in xyz] return ( (116.0 * fy) - 16.0, @@ -103,7 +112,8 @@ class Lab(LabBase): """Lab class.""" SPACE = "lab" - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + SERIALIZE = ("--lab",) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) WHITE = "D50" @classmethod diff --git a/lib/coloraide/css/spaces/lab.py b/lib/coloraide/spaces/lab/css.py similarity index 98% rename from lib/coloraide/css/spaces/lab.py rename to lib/coloraide/spaces/lab/css.py index 2b27470d..7d8d6225 100644 --- a/lib/coloraide/css/spaces/lab.py +++ b/lib/coloraide/spaces/lab/css.py @@ -1,11 +1,11 @@ """Lab class.""" import re -from ...spaces import lab as generic +from . import base from ...spaces import _parse from ... import util -class Lab(generic.Lab): +class Lab(base.Lab): """Lab class.""" DEF_VALUE = "lab(0% 0 0 / 1)" diff --git a/lib/coloraide/spaces/lab_d65.py b/lib/coloraide/spaces/lab_d65.py index 92ac2b20..de510ab7 100644 --- a/lib/coloraide/spaces/lab_d65.py +++ b/lib/coloraide/spaces/lab_d65.py @@ -1,7 +1,7 @@ """Lab D65 class.""" from ..spaces import RE_DEFAULT_MATCH from .xyz import XYZ -from .lab import LabBase, lab_to_xyz, xyz_to_lab +from .lab.base import LabBase, lab_to_xyz, xyz_to_lab import re @@ -9,7 +9,8 @@ class LabD65(LabBase): """Lab D65 class.""" SPACE = "lab-d65" - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + SERIALIZE = ("--lab-d65",) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) WHITE = "D65" @classmethod diff --git a/lib/coloraide/spaces/lch/__init__.py b/lib/coloraide/spaces/lch/__init__.py new file mode 100644 index 00000000..6711049a --- /dev/null +++ b/lib/coloraide/spaces/lch/__init__.py @@ -0,0 +1 @@ +"""Lch color class.""" diff --git a/lib/coloraide/spaces/lch.py b/lib/coloraide/spaces/lch/base.py similarity index 93% rename from lib/coloraide/spaces/lch.py rename to lib/coloraide/spaces/lch/base.py index 91dabae9..b43b75ff 100644 --- a/lib/coloraide/spaces/lch.py +++ b/lib/coloraide/spaces/lch/base.py @@ -1,7 +1,7 @@ """Lch class.""" -from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle, Percent -from .lab import Lab -from .. import util +from ...spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle, Percent +from ..lab.base import Lab +from ... import util import re import math @@ -107,7 +107,8 @@ class Lch(LchBase): """Lch class.""" SPACE = "lch" - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + SERIALIZE = ("--lch",) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) WHITE = "D50" @classmethod diff --git a/lib/coloraide/css/spaces/lch.py b/lib/coloraide/spaces/lch/css.py similarity index 98% rename from lib/coloraide/css/spaces/lch.py rename to lib/coloraide/spaces/lch/css.py index 81a93b6e..26d441ae 100644 --- a/lib/coloraide/css/spaces/lch.py +++ b/lib/coloraide/spaces/lch/css.py @@ -1,11 +1,11 @@ """Lch class.""" import re -from ...spaces import lch as generic +from . import base from ...spaces import _parse from ... import util -class Lch(generic.Lch): +class Lch(base.Lch): """Lch class.""" DEF_VALUE = "lch(0% 0 0 / 1)" diff --git a/lib/coloraide/spaces/lch_d65.py b/lib/coloraide/spaces/lch_d65.py index eeaf9709..f2a26c34 100644 --- a/lib/coloraide/spaces/lch_d65.py +++ b/lib/coloraide/spaces/lch_d65.py @@ -1,7 +1,7 @@ """Lch D65 class.""" from ..spaces import RE_DEFAULT_MATCH from .lab_d65 import LabD65 -from .lch import LchBase, lch_to_lab, lab_to_lch +from .lch.base import LchBase, lch_to_lab, lab_to_lch import re @@ -9,7 +9,8 @@ class LchD65(LchBase): """Lch D65 class.""" SPACE = "lch-d65" - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + SERIALIZE = ("--lch-d65",) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) WHITE = "D65" @classmethod diff --git a/lib/coloraide/spaces/lchuv.py b/lib/coloraide/spaces/lchuv.py new file mode 100644 index 00000000..544dec62 --- /dev/null +++ b/lib/coloraide/spaces/lchuv.py @@ -0,0 +1,126 @@ +"""LCH class.""" +from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle, Percent +from .luv import Luv +from .. import util +import re +import math + +ACHROMATIC_THRESHOLD = 0.000000000002 + + +def luv_to_lchuv(luv): + """Luv to Lch(uv).""" + + l, u, v = luv + + c = math.sqrt(u ** 2 + v ** 2) + h = math.degrees(math.atan2(v, u)) + + # Achromatic colors will often get extremely close, but not quite hit zero. + # Essentially, we want to discard noise through rounding and such. + if c < ACHROMATIC_THRESHOLD: + h = util.NaN + + return [l, c, util.constrain_hue(h)] + + +def lchuv_to_luv(lchuv): + """Lch(uv) to Luv.""" + + l, c, h = lchuv + h = util.no_nan(h) + + # If, for whatever reason (mainly direct user input), + # if chroma is less than zero, clamp to zero. + if c < 0.0: + c = 0.0 + + return ( + l, + c * math.cos(math.radians(h)), + c * math.sin(math.radians(h)) + ) + + +class Lchuv(Cylindrical, Space): + """Lch(uv) class.""" + + SPACE = "lchuv" + SERIALIZE = ("--lchuv",) + CHANNEL_NAMES = ("lightness", "chroma", "hue", "alpha") + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) + WHITE = "D65" + + RANGE = ( + GamutUnbound([Percent(0), Percent(100.0)]), + GamutUnbound([0.0, 176.0]), + GamutUnbound([Angle(0.0), Angle(360.0)]), + ) + + @property + def lightness(self): + """Lightness.""" + + return self._coords[0] + + @lightness.setter + def lightness(self, value): + """Get true luminance.""" + + self._coords[0] = self._handle_input(value) + + @property + def chroma(self): + """Chroma.""" + + return self._coords[1] + + @chroma.setter + def chroma(self, value): + """chroma.""" + + self._coords[1] = self._handle_input(value) + + @property + def hue(self): + """Hue.""" + + return self._coords[2] + + @hue.setter + def hue(self, value): + """Shift the hue.""" + + self._coords[2] = self._handle_input(value) + + @classmethod + def null_adjust(cls, coords, alpha): + """On color update.""" + + if coords[1] < ACHROMATIC_THRESHOLD: + coords[2] = util.NaN + return coords, alpha + + @classmethod + def _to_luv(cls, parent, lchuv): + """To Luv.""" + + return lchuv_to_luv(lchuv) + + @classmethod + def _from_luv(cls, parent, luv): + """To Luv.""" + + return luv_to_lchuv(luv) + + @classmethod + def _to_xyz(cls, parent, lchuv): + """To XYZ.""" + + return Luv._to_xyz(parent, cls._to_luv(parent, lchuv)) + + @classmethod + def _from_xyz(cls, parent, xyz): + """From XYZ.""" + + return cls._from_luv(parent, Luv._from_xyz(parent, xyz)) diff --git a/lib/coloraide/spaces/luv.py b/lib/coloraide/spaces/luv.py new file mode 100644 index 00000000..ec8baddf --- /dev/null +++ b/lib/coloraide/spaces/luv.py @@ -0,0 +1,126 @@ +""" +Luv class. + +https://en.wikipedia.org/wiki/CIELUV +""" +from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Percent, WHITES +from .xyz import XYZ +from .. import util +import re + + +def xyz_to_uv(xyz): + """XYZ to UV.""" + + x, y, z = xyz + denom = (x + 15 * y + 3 * z) + if denom != 0: + u = (4 * x) / (x + 15 * y + 3 * z) + v = (9 * y) / (x + 15 * y + 3 * z) + else: + u = v = 0 + + return u, v + + +def xyz_to_luv(xyz, white): + """XYZ to Luv.""" + + u, v = xyz_to_uv(xyz) + un, vn = xyz_to_uv(WHITES[white]) + + y = xyz[1] / WHITES[white][1] + l = 116 * util.nth_root(y, 3) - 16 if y > ((6 / 29) ** 3) else ((29 / 3) ** 3) * y + + return [ + l, + 13 * l * (u - un), + 13 * l * (v - vn), + ] + + +def luv_to_xyz(luv, white): + """Luv to XYZ.""" + + l, u, v = luv + un, vn = xyz_to_uv(WHITES[white]) + + if l != 0: + up = (u / ( 13 * l)) + un + vp = (v / ( 13 * l)) + vn + else: + up = vp = 0 + + y = WHITES[white][1] * ((l + 16) / 116) ** 3 if l > 8 else WHITES[white][1] * l * ((3 / 29) ** 3) + + if vp != 0: + x = y * ((9 * up) / (4 * vp)) + z = y * ((12 - 3 * up - 20 * vp) / (4 * vp)) + else: + x = z = 0 + + return [x, y, z] + + +class Luv(Space): + """Oklab class.""" + + SPACE = "luv" + SERIALIZE = ("--luv",) + CHANNEL_NAMES = ("lightness", "u", "v", "alpha") + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) + WHITE = "D65" + + RANGE = ( + GamutUnbound([Percent(0), Percent(100.0)]), + GamutUnbound([-175.0, 175.0]), + GamutUnbound([-175.0, 175.0]) + ) + + @property + def lightness(self): + """L channel.""" + + return self._coords[0] + + @lightness.setter + def lightness(self, value): + """Get true luminance.""" + + self._coords[0] = self._handle_input(value) + + @property + def u(self): + """U channel.""" + + return self._coords[1] + + @u.setter + def u(self, value): + """U axis.""" + + self._coords[1] = self._handle_input(value) + + @property + def v(self): + """V channel.""" + + return self._coords[2] + + @v.setter + def v(self, value): + """V axis.""" + + self._coords[2] = self._handle_input(value) + + @classmethod + def _to_xyz(cls, parent, luv): + """To XYZ.""" + + return parent.chromatic_adaptation(cls.WHITE, XYZ.WHITE, luv_to_xyz(luv, cls.WHITE)) + + @classmethod + def _from_xyz(cls, parent, xyz): + """From XYZ.""" + + return xyz_to_luv(parent.chromatic_adaptation(XYZ.WHITE, cls.WHITE, xyz), cls.WHITE) diff --git a/lib/coloraide/spaces/oklab.py b/lib/coloraide/spaces/oklab.py index 61991d7e..de7ed14e 100644 --- a/lib/coloraide/spaces/oklab.py +++ b/lib/coloraide/spaces/oklab.py @@ -3,7 +3,7 @@ https://bottosson.github.io/posts/oklab/ """ -from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound +from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, OptionalPercent from .xyz import XYZ from .. import util import re @@ -49,12 +49,13 @@ class Oklab(Space): """Oklab class.""" SPACE = "oklab" + SERIALIZE = ("--oklab",) CHANNEL_NAMES = ("lightness", "a", "b", "alpha") - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) WHITE = "D65" RANGE = ( - GamutUnbound([0, 1]), + GamutUnbound([OptionalPercent(0), OptionalPercent(1)]), GamutUnbound([-0.5, 0.5]), GamutUnbound([-0.5, 0.5]) ) diff --git a/lib/coloraide/spaces/oklch.py b/lib/coloraide/spaces/oklch.py index c8af4dcd..3281bbd2 100644 --- a/lib/coloraide/spaces/oklch.py +++ b/lib/coloraide/spaces/oklch.py @@ -1,5 +1,5 @@ """LCH class.""" -from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle +from ..spaces import Space, RE_DEFAULT_MATCH, GamutUnbound, Cylindrical, Angle, OptionalPercent from .oklab import Oklab from .. import util import re @@ -46,12 +46,13 @@ class Oklch(Cylindrical, Space): """Oklch class.""" SPACE = "oklch" + SERIALIZE = ("--oklch",) CHANNEL_NAMES = ("lightness", "chroma", "hue", "alpha") - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) WHITE = "D65" RANGE = ( - GamutUnbound([0.0, 1.0]), + GamutUnbound([OptionalPercent(0), OptionalPercent(1)]), GamutUnbound([0.0, 1.0]), GamutUnbound([Angle(0.0), Angle(360.0)]), ) diff --git a/lib/coloraide/spaces/prophoto_rgb.py b/lib/coloraide/spaces/prophoto_rgb.py index 936427a5..633ff162 100644 --- a/lib/coloraide/spaces/prophoto_rgb.py +++ b/lib/coloraide/spaces/prophoto_rgb.py @@ -1,6 +1,6 @@ """Pro Photo RGB color class.""" from ..spaces import RE_DEFAULT_MATCH -from .srgb import SRGB +from .srgb.base import SRGB from .xyz import XYZ from .. import util import re @@ -80,7 +80,7 @@ class ProPhotoRGB(SRGB): """Pro Photo RGB class.""" SPACE = "prophoto-rgb" - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE, channels=3)) WHITE = "D50" @classmethod diff --git a/lib/coloraide/spaces/rec2020.py b/lib/coloraide/spaces/rec2020.py index f24e007c..663ca0b3 100644 --- a/lib/coloraide/spaces/rec2020.py +++ b/lib/coloraide/spaces/rec2020.py @@ -1,6 +1,6 @@ """Rec 2020 color class.""" from ..spaces import RE_DEFAULT_MATCH -from .srgb import SRGB +from .srgb.base import SRGB from .xyz import XYZ from .. import util import re @@ -80,7 +80,7 @@ class Rec2020(SRGB): """Rec 2020 class.""" SPACE = "rec2020" - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE, channels=3)) WHITE = "D65" @classmethod diff --git a/lib/coloraide/spaces/srgb/__init__.py b/lib/coloraide/spaces/srgb/__init__.py new file mode 100644 index 00000000..f978475c --- /dev/null +++ b/lib/coloraide/spaces/srgb/__init__.py @@ -0,0 +1 @@ +"""SRGB color class.""" diff --git a/lib/coloraide/spaces/srgb.py b/lib/coloraide/spaces/srgb/base.py similarity index 90% rename from lib/coloraide/spaces/srgb.py rename to lib/coloraide/spaces/srgb/base.py index f19edc50..ff0ffcd1 100644 --- a/lib/coloraide/spaces/srgb.py +++ b/lib/coloraide/spaces/srgb/base.py @@ -1,7 +1,7 @@ """SRGB color class.""" -from ..spaces import RE_DEFAULT_MATCH, Space, GamutBound -from .xyz import XYZ -from .. import util +from ...spaces import RE_DEFAULT_MATCH, Space, GamutBound, OptionalPercent +from ..xyz import XYZ +from ... import util import re import math @@ -77,14 +77,14 @@ class SRGB(Space): # In addition to the current gamut, check HSL as it is much more sensitive to small # gamut changes. This is mainly for a better user experience. Colors will still be # mapped/clipped in the current space, unless specified otherwise. - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE, channels=3)) CHANNEL_NAMES = ("red", "green", "blue", "alpha") WHITE = "D65" RANGE = ( - GamutBound([0.0, 1.0]), - GamutBound([0.0, 1.0]), - GamutBound([0.0, 1.0]) + GamutBound([OptionalPercent(0.0), OptionalPercent(1.0)]), + GamutBound([OptionalPercent(0.0), OptionalPercent(1.0)]), + GamutBound([OptionalPercent(0.0), OptionalPercent(1.0)]) ) @property diff --git a/lib/coloraide/css/spaces/_color_names.py b/lib/coloraide/spaces/srgb/color_names.py similarity index 100% rename from lib/coloraide/css/spaces/_color_names.py rename to lib/coloraide/spaces/srgb/color_names.py diff --git a/lib/coloraide/css/spaces/srgb.py b/lib/coloraide/spaces/srgb/css.py similarity index 96% rename from lib/coloraide/css/spaces/srgb.py rename to lib/coloraide/spaces/srgb/css.py index 52fd67e2..c3aa69a5 100644 --- a/lib/coloraide/css/spaces/srgb.py +++ b/lib/coloraide/spaces/srgb/css.py @@ -1,14 +1,14 @@ """SRGB color class.""" import re -from . import _color_names -from ...spaces import srgb as generic -from ...spaces import _parse +from . import color_names +from . import base +from .. import _parse from ... import util RE_COMPRESS = re.compile(r'(?i)^#({hex})\1({hex})\2({hex})\3(?:({hex})\4)?$'.format(**_parse.COLOR_PARTS)) -class SRGB(generic.SRGB): +class SRGB(base.SRGB): """SRGB class.""" DEF_VALUE = "rgb(0 0 0 / 1)" @@ -75,7 +75,7 @@ def to_string( index = int(length / 4) if length in (8, 4) and h[-index:].lower() == ("f" * index): h = h[:-index] - n = _color_names.hex2name(h) + n = color_names.hex2name(h) if n is not None: value = n @@ -197,7 +197,7 @@ def match(cls, string, start=0, fullmatch=True): m = cls.MATCH.match(string, start) if m is not None and (not fullmatch or m.end(0) == len(string)): if not string[start:start + 5].lower().startswith(('#', 'rgb(', 'rgba(')): - string = _color_names.name2hex(string[m.start(0):m.end(0)]) + string = color_names.name2hex(string[m.start(0):m.end(0)]) if string is not None: return cls.split_channels(string), m.end(0) else: diff --git a/lib/coloraide/spaces/srgb_linear.py b/lib/coloraide/spaces/srgb_linear.py index bea5ec8a..e55f4929 100644 --- a/lib/coloraide/spaces/srgb_linear.py +++ b/lib/coloraide/spaces/srgb_linear.py @@ -1,6 +1,6 @@ """SRGB Linear color class.""" from ..spaces import RE_DEFAULT_MATCH -from .srgb import SRGB, lin_srgb_to_xyz, xyz_to_lin_srgb, lin_srgb, gam_srgb +from .srgb.base import SRGB, lin_srgb_to_xyz, xyz_to_lin_srgb, lin_srgb, gam_srgb from .xyz import XYZ import re @@ -9,7 +9,8 @@ class SRGBLinear(SRGB): """SRGB linear.""" SPACE = "srgb-linear" - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + SERIALIZE = ("--srgb-linear",) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) WHITE = "D65" @classmethod diff --git a/lib/coloraide/spaces/xyz.py b/lib/coloraide/spaces/xyz.py index 1aadd569..e3444e52 100644 --- a/lib/coloraide/spaces/xyz.py +++ b/lib/coloraide/spaces/xyz.py @@ -7,8 +7,9 @@ class XYZ(Space): """XYZ class.""" SPACE = "xyz" + SERIALIZE = ("xyz", "--xyz-d50") CHANNEL_NAMES = ("x", "y", "z", "alpha") - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) WHITE = "D50" RANGE = ( diff --git a/lib/coloraide/spaces/xyz_d65.py b/lib/coloraide/spaces/xyz_d65.py index eebcff16..979e7161 100644 --- a/lib/coloraide/spaces/xyz_d65.py +++ b/lib/coloraide/spaces/xyz_d65.py @@ -8,8 +8,9 @@ class XYZD65(XYZ): """XYZ D65 class.""" SPACE = "xyz-d65" + SERIALIZE = ("--xyz-d65",) CHANNEL_NAMES = ("x", "y", "z", "alpha") - DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space=SPACE)) + DEFAULT_MATCH = re.compile(RE_DEFAULT_MATCH.format(color_space='|'.join(SERIALIZE), channels=3)) WHITE = "D65" RANGE = ( From b3f0c3c45c36bb1f2801788707479688e615ab16 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Sun, 5 Sep 2021 15:45:20 -0600 Subject: [PATCH 05/12] Fix lint --- ch_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ch_util.py b/ch_util.py index 9e3ae36b..da6e8f72 100644 --- a/ch_util.py +++ b/ch_util.py @@ -22,7 +22,7 @@ PALETTE_FMT = (1, 0) RE_COLOR_START = \ -r"(?i)(?:\b(? Date: Sun, 5 Sep 2021 16:12:20 -0600 Subject: [PATCH 06/12] test fix --- tests/test_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_json.py b/tests/test_json.py index e8c55ec2..744d9998 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -14,7 +14,7 @@ def _get_json_files(self, pattern, folder='.'): for root, dirnames, filenames in os.walk(folder): for filename in fnmatch.filter(filenames, pattern): yield os.path.join(root, filename) - dirnames = [d for d in dirnames if d not in ('.svn', '.git', '.tox')] + dirnames[:] = [d for d in dirnames if d not in ('.svn', '.git', '.tox')] def test_json_settings(self): """Test each JSON file.""" From 3b733c4e39047f60b0a213b3d911542f7a226d22 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 6 Sep 2021 09:46:06 -0600 Subject: [PATCH 07/12] Implement Advanced Substrate Alpha (ASS) support by @jfcherng Takes @jfcherng's changes and makes adjustments in naming and fixing lint etc. Resolves #191 --- CHANGES.md | 4 ++ color_helper.sublime-settings | 16 +++++++ custom/ass_abgr.py | 85 +++++++++++++++++++++++++++++++++++ messages.json | 2 +- messages/recent.md | 21 ++------- support.py | 2 +- 6 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 custom/ass_abgr.py diff --git a/CHANGES.md b/CHANGES.md index 60cee7ca..256f73c0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # ColorHelper +## 3.6.0 + +- **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 diff --git a/color_helper.sublime-settings b/color_helper.sublime-settings index dab27af2..43cb521d 100755 --- a/color_helper.sublime-settings +++ b/color_helper.sublime-settings @@ -211,6 +211,14 @@ "output": [ {"space": "srgb", "format": {"hex": true}} ] + }, + // for color codes like `&HAABBGGRR` and `&HBBGGRR` + "ass_abgr": { + "class": "ColorHelper.custom.ass_abgr.ColorAssABGR", + "filters": ["srgb"], + "output": [ + {"space": "srgb", "format": {"upper": true}} + ] } }, @@ -379,6 +387,14 @@ "constant.color.w3c-standard-color-name.css", "meta.property-value.css" ] + }, + { + // ASS ( based on: https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS) ) + "name": "ASS", + "base_scopes": ["text.ass"], + "scanning": ["constant.other.color"], + "color_class": "ass_abgr", + "color_trigger": "(?i)&h" } ], diff --git a/custom/ass_abgr.py b/custom/ass_abgr.py new file mode 100644 index 00000000..1d620e77 --- /dev/null +++ b/custom/ass_abgr.py @@ -0,0 +1,85 @@ +"""Custom color that looks for colors of format `&HAABBGGRR` as `#AARRGGBB`.""" +from ColorHelper.lib.coloraide import Color +from ColorHelper.lib.coloraide import util +from ColorHelper.lib.coloraide.spaces import _parse +from ColorHelper.lib.coloraide.spaces.srgb.css import SRGB +import copy +import re + + +class AssABGR(SRGB): + """ASS `ABGR` color space.""" + + MATCH = re.compile(r"&H([0-9a-fA-f]{8}|[0-9a-fA-f]{6})\b") + + @classmethod + def match(cls, string: str, start: int = 0, fullmatch: bool = True): + """Match a color string.""" + + m = cls.MATCH.match(string, start) + if m is not None and (not fullmatch or m.end(0) == len(string)): + return cls.split_channels(m.group(1)), m.end(0) + return None, None + + @classmethod + def translate_channel(cls, channel: int, value: str): + """Translate channel string.""" + + if -1 <= channel <= 2: + return _parse.norm_hex_channel(value) + + @classmethod + def split_channels(cls, color: str): + """Split string into the appropriate channels.""" + + # convert `BBGGRR` to `AABBGGRR` + if len(color) == 6: + color = "00" + color + # deal with `AABBGGRR` + if len(color) == 8: + return cls.null_adjust( + ( + cls.translate_channel(0, "#" + color[6:]), # RR + cls.translate_channel(1, "#" + color[4:6]), # GG + cls.translate_channel(2, "#" + color[2:4]), # BB + ), + 1 - cls.translate_channel(-1, "#" + color[:2]), # AA + ) + + raise RuntimeError("Something is wrong in code logics.") + + def to_string(self, parent, *, options=None, alpha=None, precision=None, fit=True, **kwargs): + """Convert color to `&HAABBGGRR`.""" + + options = kwargs + a = util.no_nan(self.alpha) + show_alpha = alpha is not False and (alpha is True or a < 1.0) + + template = "&H{:02x}{:02x}{:02x}{:02x}" if show_alpha else "&H{:02x}{:02x}{:02x}" + if options.get("upper"): + template = template.upper() + + # Always fit hex + method = None if not isinstance(fit, str) else fit + coords = util.no_nan(parent.fit(method=method).coords()) + if show_alpha: + value = template.format( + int(util.round_half_up(a * 255.0)), + int(util.round_half_up(coords[2] * 255.0)), + int(util.round_half_up(coords[1] * 255.0)), + int(util.round_half_up(coords[0] * 255.0)), + ) + else: + value = template.format( + int(util.round_half_up(coords[2] * 255.0)), + int(util.round_half_up(coords[1] * 255.0)), + int(util.round_half_up(coords[0] * 255.0)), + ) + return value + + +class ColorAssABGR(Color): + """Color class for ASS `ABGR` colors.""" + + CS_MAP = copy.copy(Color.CS_MAP) + CS_MAP["srgb"] = AssABGR diff --git a/messages.json b/messages.json index b7fd79da..486e6875 100644 --- a/messages.json +++ b/messages.json @@ -1,4 +1,4 @@ { "install": "messages/install.md", - "3.5.0": "messages/recent.md" + "3.6.0": "messages/recent.md" } diff --git a/messages/recent.md b/messages/recent.md index 025b48b9..1d4a6ccc 100644 --- a/messages/recent.md +++ b/messages/recent.md @@ -1,4 +1,4 @@ -# ColorHelper 3.5.0 +# ColorHelper 3.6.0 New release! @@ -7,22 +7,9 @@ prior releases. Restart of Sublime Text may be required. -## 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. +## 3.6.0 + +- **NEW**: Add support for [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)). ## Updated from 2.0 to 3.0? diff --git a/support.py b/support.py index edcc7c0e..623c4a80 100644 --- a/support.py +++ b/support.py @@ -5,7 +5,7 @@ import webbrowser import re -__version__ = "3.5.0" +__version__ = "3.6.0" __pc_name__ = 'ColorHelper' CSS = ''' From 2e88dfe485e0d1f00c3960f8c343e4ff5f9780c4 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Mon, 6 Sep 2021 10:44:19 -0600 Subject: [PATCH 08/12] Update docs with duplicate color FAQ --- docs/src/dictionary/en-custom.txt | 2 + docs/src/markdown/faq.md | 62 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt index 9d6be82d..1db6e125 100644 --- a/docs/src/dictionary/en-custom.txt +++ b/docs/src/dictionary/en-custom.txt @@ -6,6 +6,7 @@ Changelog ColorAide ColorAide's ColorHelper +ColorHelper's ColorMod ColorPicker Control's @@ -20,6 +21,7 @@ Inline JSON KDE LCH +LSP MERCHANTABILITY MacOS MkDocs diff --git a/docs/src/markdown/faq.md b/docs/src/markdown/faq.md index 76a1eaa8..e5538fce 100644 --- a/docs/src/markdown/faq.md +++ b/docs/src/markdown/faq.md @@ -1,5 +1,67 @@ # Frequently Asked Questions +## Duplicate Colors? + +If you are seeing duplicate color previews, it may be because you have an LSP server installed that is injecting its own +previews or some other package. While we can't provide an exhaustive list, we've provided a few known examples. + +Often, the duplicate colors may have a slightly different style, and when you click them, they will not not open the +ColorHelper dialog. + +### LSP +Two such examples are [`LSP-css`](https://packagecontrol.io/packages/LSP-css) and +[`LSP-json`](https://packagecontrol.io/packages/LSP-json). + +The solution is to disable either ColorHelper or the color provider for the LSP package. If you are here, you probably +enjoy ColorHelper's features and would prefer to disable the LSP package provider. If so, you can do the following. + +For `LSP-css`: + +In case it's `LSP-css`, you can disable the color boxes as follows: Run `Preferences: LSP-css Settings` from the +Command Palette. Then add: + +```js + "disabled_capabilities": { + "colorProvider": true, + }, +``` + +For `LSP-json`: + +In case it's `LSP-json`, you can disable the color boxes as follows: Run `Preferences: LSP-json Settings` from the +Command Palette. Then add: + +```js +{ + "disabled_capabilities": { + // the trigger characters are too blunt, we'll specify auto_complete_selector manually + "completionProvider": { + "triggerCharacters": true + }, + "colorProvider": true + } +} +``` + +There may be other LSP packages. It is assumed the approach would be similar for all of them. Just make sure to check +what the default disabled capabilities are and copy them over in addition to adding your own. + +### Advanced Substation Alpha (ASS) + +One package that comes with color previews out of the box is the [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)) package. + +You can disable their color previews by modifying it's settings with the following. + +Navigate to `Preferences -> Package Settings -> Advanced Substation Alpha (ASS) -> Settings`. Then add: + +```js +{ + // when to show a color phantom beside a color code? + // can be "never", "always" or "hover" + "show_color_phantom": "never", +} +``` + ## Hex Uppercase > How do I output hex in uppercase? From 5ff883e527f22a8454cbd81bca593b704d90228d Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 7 Sep 2021 17:08:35 -0600 Subject: [PATCH 09/12] Remove extra dependencies --- dependencies.json | 6 ------ support.py | 21 --------------------- 2 files changed, 27 deletions(-) diff --git a/dependencies.json b/dependencies.json index 7b7d0f68..d9cdca70 100644 --- a/dependencies.json +++ b/dependencies.json @@ -1,13 +1,7 @@ { "*": { ">=3124": [ - "pygments", - "python-markdown", "mdpopups", - "python-jinja2", - "markupsafe", - "pyyaml", - "pymdownx" ] } } diff --git a/support.py b/support.py index 623c4a80..f9ddc6f3 100644 --- a/support.py +++ b/support.py @@ -68,24 +68,6 @@ def run(self): except Exception: info["mdpopups_version"] = 'Version could not be acquired!' - try: - import markdown - info["markdown_version"] = format_version(markdown, 'version') - except Exception: - info["markdown_version"] = 'Version could not be acquired!' - - try: - import jinja2 - info["jinja_version"] = format_version(jinja2, '__version__') - except Exception: - info["jinja_version"] = 'Version could not be acquired!' - - try: - import pygments - info["pygments_version"] = format_version(pygments, '__version__') - except Exception: - info["pygments_version"] = 'Version could not be acquired!' - msg = textwrap.dedent( """\ - ST ver.: %(version)s @@ -94,9 +76,6 @@ def run(self): - Plugin ver.: %(plugin_version)s - Install via PC: %(pc_install)s - mdpopups ver.: %(mdpopups_version)s - - markdown ver.: %(markdown_version)s - - pygments ver.: %(pygments_version)s - - jinja2 ver.: %(jinja_version)s """ % info ) From e189456f94f76785256b3fc64372343b72a5a8d8 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 7 Sep 2021 17:13:26 -0600 Subject: [PATCH 10/12] Update changelog --- CHANGES.md | 4 ++++ support.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 256f73c0..20560d04 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # ColorHelper +## 3.6.1 + +- **FIX**: Remove unnecessary dependencies. + ## 3.6.0 - **NEW**: Add support for [Advanced Substation Alpha (ASS)](https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)). diff --git a/support.py b/support.py index f9ddc6f3..c3e1dc5f 100644 --- a/support.py +++ b/support.py @@ -5,7 +5,7 @@ import webbrowser import re -__version__ = "3.6.0" +__version__ = "3.6.1" __pc_name__ = 'ColorHelper' CSS = ''' From 8cb8ffb8679b3e7fcce441ece434c53069061abf Mon Sep 17 00:00:00 2001 From: Jack Cherng Date: Wed, 8 Sep 2021 10:10:07 +0800 Subject: [PATCH 11/12] Allow loosy ASS/SSA color codes (#192) * Allow loosy ASS/SSA color codes Signed-off-by: Jack Cherng * Use named regex capture group in AssABGR.MATCH Signed-off-by: Jack Cherng * fixup: lossy color codes Signed-off-by: Jack Cherng * fixup: normalize the trailing "&" in color codes Signed-off-by: Jack Cherng --- color_helper.sublime-settings | 2 +- custom/ass_abgr.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/color_helper.sublime-settings b/color_helper.sublime-settings index 43cb521d..c237a505 100755 --- a/color_helper.sublime-settings +++ b/color_helper.sublime-settings @@ -394,7 +394,7 @@ "base_scopes": ["text.ass"], "scanning": ["constant.other.color"], "color_class": "ass_abgr", - "color_trigger": "(?i)&h" + "color_trigger": "(?:&H|(?<=\\\\[1-4]c)|(?<=\\\\c))[0-9a-fA-F]" } ], diff --git a/custom/ass_abgr.py b/custom/ass_abgr.py index 1d620e77..e9079128 100644 --- a/custom/ass_abgr.py +++ b/custom/ass_abgr.py @@ -10,7 +10,7 @@ class AssABGR(SRGB): """ASS `ABGR` color space.""" - MATCH = re.compile(r"&H([0-9a-fA-f]{8}|[0-9a-fA-f]{6})\b") + MATCH = re.compile(r"(?P&H)?(?P[0-9a-fA-F]{1,8})(?P&|\b)") @classmethod def match(cls, string: str, start: int = 0, fullmatch: bool = True): @@ -18,7 +18,7 @@ def match(cls, string: str, start: int = 0, fullmatch: bool = True): m = cls.MATCH.match(string, start) if m is not None and (not fullmatch or m.end(0) == len(string)): - return cls.split_channels(m.group(1)), m.end(0) + return cls.split_channels(m.group("color")), m.end(0) return None, None @classmethod @@ -32,9 +32,10 @@ def translate_channel(cls, channel: int, value: str): def split_channels(cls, color: str): """Split string into the appropriate channels.""" - # convert `BBGGRR` to `AABBGGRR` - if len(color) == 6: - color = "00" + color + # convert `RR` / `GGRR` / `BBGGRR` to `AABBGGRR` + # consecutive leading 0s can be omitted and the alpha is 00 (opaque) by default + color = color.zfill(8) + # deal with `AABBGGRR` if len(color) == 8: return cls.null_adjust( From b717bd82ec8325517ba1d8fd5ab0fc7937e56b38 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 7 Sep 2021 20:14:02 -0600 Subject: [PATCH 12/12] Update changelog and fix dependency file. --- CHANGES.md | 2 ++ dependencies.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 20560d04..88dc801a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## 3.6.1 +- **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 diff --git a/dependencies.json b/dependencies.json index d9cdca70..44930629 100644 --- a/dependencies.json +++ b/dependencies.json @@ -1,7 +1,7 @@ { "*": { ">=3124": [ - "mdpopups", + "mdpopups" ] } }