diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index eaa2150b..92c8aae9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ -Thanks you for contributing to this project! Make sure you've read: https://codebyzach.github.io/sublime_color_helper/contributing/. Please follow the guidelines below. +Thanks you for contributing to this project! Make sure you've read: https://codebyzach.github.io/sublime_color_helper/contributing/. Please follow the guidelines below. - Please describe the change in as much detail as possible so I can understand what is being added or modified. @@ -6,4 +6,4 @@ Thanks you for contributing to this project! Make sure you've read: https://cod - Please reference and link related open bugs or feature requests in this pull if applicable. -- Make sure you've documented or updated the existing documentation if introducing a new feature or modifying the behavior of an existing feature that a user needs to be aware of. I will not accept new features if you have not provided documentation describing the feature. +- Make sure you've documented or updated the existing documentation if introducing a new feature or modifying the behavior of an existing feature that a user needs to be aware of. I will not accept new features if you have not provided documentation describing the feature. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4dd9e937..5bd86518 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: python-version: 3.9 - name: Install dependencies run: | - python -m pip install --upgrade pip setuptools tox + python -m pip install --upgrade pip setuptools tox build - name: Tests run: | python -m tox @@ -46,7 +46,7 @@ jobs: python-version: 3.9 - name: Install dependencies run: | - python -m pip install --upgrade pip setuptools tox + python -m pip install --upgrade pip setuptools tox build - name: Lint run: | python -m tox @@ -66,7 +66,7 @@ jobs: python-version: 3.9 - name: Install dependencies run: | - python -m pip install --upgrade pip setuptools tox + python -m pip install --upgrade pip setuptools tox build - name: Install Aspell run: | sudo apt-get install aspell aspell-en diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b7674573..fcd1a724 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,7 +21,7 @@ jobs: python-version: 3.9 - name: Install dependencies run: | - python -m pip install --upgrade pip setuptools + python -m pip install --upgrade pip setuptools build python -m pip install -r docs/src/requirements.txt - name: Deploy documents run: | diff --git a/CHANGES.md b/CHANGES.md index 51646970..c928183f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,31 @@ # ColorHelper +## 5.0.0 + +> **BREAKING CHANGE**: Newest `coloraide` was updated. It is approaching +> a 1.0 release. In the path to a 1.0 release some refactoring and +> reworking caused custom color classes to break. All internal color +> classes should be fine, but any users that created custom local +> color classes will need to update the color classes and color spaces +> to work with the latest version. + +- **NEW**: Upgrade to latest `coloraide`. +- **NEW**: Many new color spaces have been added and can optionally + be included via the new `add_to_default_spaces` option. Some that + were available previously are no longer registered by default. + See `add_to_default_spaces` in the settings file to enable more + spaces. A restart of Sublime Text is required when changing this + setting. +- **NEW**: Add new `add_to_default_spaces` which allows a user to add + NEW color spaces to the default color space so that the new spaces + can be saved and recognized in palettes and other areas of ColorHelper. + Modifying this setting requires a restart of Sublime Text. Custom + color classes should only be used to modifying previously added + color spaces to add to recognized input and output formats. + ## 4.3.1 -- **NEW**: Upgrade underlying `coloraide` library to fix a color parsing +- **NEW**: Upgrade underlying `coloraide` library to fix a color parsing bug. ## 4.3.0 diff --git a/ColorHelper.sublime-settings b/ColorHelper.sublime-settings index fc6d92e4..7a11d669 100644 --- a/ColorHelper.sublime-settings +++ b/ColorHelper.sublime-settings @@ -74,7 +74,7 @@ "auto_color_picker_mode": true, // If "auto" mode is disabled, or the "auto" mode could not determine a suitable picker, - // the preferrreed color picker space will be used. If the preferred is invalid, the + // the preferred color picker space will be used. If the preferred is invalid, the // first picker from `enabled_color_picker_modes` will be used, and if that is not valid, // `srgb` will be used. "preferred_color_picker_mode": "hsl", @@ -112,6 +112,37 @@ // Color Rules ////////////////// + // This option requires a restart of Sublime Text. + // This allows a user to add NEW previously unincluded color spaces. + // This ensures that the new color space will work in palettes etc. + // Then, custom color classes can override the space for special, file + // specific formatting via the `class` attribute under `user_color_classes`. + "add_to_default_spaces": [ + // "ColorHelper.lib.coloraide.spaces.cmy.CMY", + // "ColorHelper.lib.coloraide.spaces.cmyk.CMYK", + // "ColorHelper.lib.coloraide.spaces.din99o.Din99o", + // "ColorHelper.lib.coloraide.spaces.hsi.HSI", + // "ColorHelper.lib.coloraide.spaces.hunter_lab.HunterLab", + // "ColorHelper.lib.coloraide.spaces.ictcp.ICtCp", + // "ColorHelper.lib.coloraide.spaces.igtgpg.IgTgPg", + // "ColorHelper.lib.coloraide.spaces.itp.ITP", + // "ColorHelper.lib.coloraide.spaces.jzazbz.Jzazbz", + // "ColorHelper.lib.coloraide.spaces.jzczhz.JzCzhz", + // "ColorHelper.lib.coloraide.spaces.lch99o.lch99o", + // "ColorHelper.lib.coloraide.spaces.orgb.ORGB", + // "ColorHelper.lib.coloraide.spaces.prismatic.Prismatic", + // "ColorHelper.lib.coloraide.spaces.rec2100pq.Rec2100PQ", + // "ColorHelper.lib.coloraide.spaces.rlab.RLAB", + // "ColorHelper.lib.coloraide.spaces.ucs.UCS", + // "ColorHelper.lib.coloraide.spaces.uvw.UVW", + // "ColorHelper.lib.coloraide.spaces.xyy.XyY", + "ColorHelper.lib.coloraide.spaces.hsluv.HSLuv", + "ColorHelper.lib.coloraide.spaces.lchuv.Lchuv", + "ColorHelper.lib.coloraide.spaces.luv.Luv", + "ColorHelper.lib.coloraide.spaces.okhsl.Okhsl", + "ColorHelper.lib.coloraide.spaces.okhsv.Okhsv" + ], + // If a there is no rule for the current file, commands will assume // the options specified here. This allows translate custom input color // formats to our defaults and back out again. Here you can also filter @@ -263,7 +294,7 @@ // - `color_class`: A string defining the name of a color class to use for the associated views. Color class // name should be defined in `color_classes`. // - // If needed, you can define multiple color classes with a list of dicitionaries. Each + // If needed, you can define multiple color classes with a list of dictionaries. Each // dictionary in the list should contain a `class` and `scopes`: // // - `scopes`: A string that defines a base scope that the color class applies to. @@ -312,10 +343,10 @@ // // All of this "could" lead to false positives. We do our best to limit scoping as // much as reasonable. HSV support is currently limited due to the fact the syntax - // is very non-spefic (floats separated by commas or spaces) and is likely to cause + // is very non-specific (floats separated by commas or spaces) and is likely to cause // the most false positives. If the GraphViz syntax were to scope colors with a // special scope, or at least properties that are likely to contain colors, we - // could greatly reduce/elliminate false positives and add HSV support with more + // could greatly reduce/eliminate false positives and add HSV support with more // confidence. "name": "Graphviz", "base_scopes": [ @@ -344,7 +375,7 @@ ] }, { - //Sass (based on: https://packagecontrol.io/packages/Sass) + // Sass (based on: https://packagecontrol.io/packages/Sass) "name": "Sass", "syntax_files": ["Sass/Syntaxes/Sass", "Sass/Syntaxes/SCSS"], "base_scopes": [ @@ -387,7 +418,7 @@ ] }, { - // Sass (based on https://packagecontrol.io/packages/Syntax%20Highlighting%20for%20PostCSS) + // Sass (based on: https://packagecontrol.io/packages/Syntax%20Highlighting%20for%20PostCSS) "name": "PostCSS", "syntax_files": ["Syntax Highlighting for PostCSS/Syntaxes/PostCSS"], "base_scopes": [ @@ -417,11 +448,11 @@ ] }, { + // Stylus (based on: https://github.com/billymoon/Stylus/blob/master/Stylus.tmLanguage) "name": "Stylus", "base_scopes": ["source.stylus"], "color_class": "css-level-4", "scanning": [ - // Based on https://github.com/billymoon/Stylus/blob/master/Stylus.tmLanguage "constant.other.color.rgb-value.stylus", "constant.color.w3c-standard-color-name.stylus", "meta.property-value.stylus" @@ -438,7 +469,7 @@ ] }, { - // ASS ( based on: https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS) ) + // ASS (based on: https://packagecontrol.io/packages/Advanced%20Substation%20Alpha%20(ASS)) "name": "ASS", "base_scopes": ["text.ass"], "scanning": ["constant.other.color"], @@ -449,11 +480,11 @@ // User rules. These will be appended to the normal `color_rules` unless they // share the same name. In that case, a shallow merge will be performed allowing - // the values of top level keys to be overriden and new keys to be added. + // the values of top level keys to be overridden and new keys to be added. "user_color_rules": [], // User color classes. These will be added to the normal `color_classes` unless they // share the same name with an existing entry. In that case, a shallow merge will be performed allowing - // the values of top level keys to be overriden and new keys to be added. + // the values of top level keys to be overridden and new keys to be added. "user_color_classes": {} } diff --git a/ch_mixin.py b/ch_mixin.py index e31101f6..bb522625 100644 --- a/ch_mixin.py +++ b/ch_mixin.py @@ -33,22 +33,22 @@ 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) - border_color.fit(self.gamut_space, in_place=True) + border_color = self.base(border_color) + border_color.fit(self.gamut_space) except Exception: border_color = None if border_color is None: # Calculate border color for images - border_color = Color( + border_color = self.base( self.view.style()['background'], filters=util.CSS_SRGB_SPACES ).convert("hsl") - border_color.lightness = border_color.lightness + (0.3 if border_color.luminance() < 0.5 else -0.3) + border_color['lightness'] = border_color['lightness'] + (0.3 if border_color.luminance() < 0.5 else -0.3) self.default_border = border_color.convert(self.gamut_space, in_place=True) - self.out_of_gamut = Color("transparent").convert(self.gamut_space, in_place=True) - self.out_of_gamut_border = Color( + self.out_of_gamut = self.base("transparent").convert(self.gamut_space, in_place=True) + self.out_of_gamut_border = self.base( self.view.style().get('redish', "red"), filters=util.CSS_SRGB_SPACES ).convert(self.gamut_space, in_place=True) @@ -61,7 +61,7 @@ def get_color_options(self, pt, rule): # Check if the first point within the color matches our scope rules # and load up the appropriate color class - color_class = Color + color_class = self.base filters = [] output = [] edit_mode = "default" @@ -88,7 +88,7 @@ def get_color_options(self, pt, rule): edit_mode = 'default' break except Exception: - color_class = Color + color_class = self.base filters = [] output = [] edit_mode = 'default' @@ -109,7 +109,7 @@ def setup_color_class(self): self.custom_color_class = color_class self.filters = filters self.output_options = output - self.color_class = Color + self.color_class = self.base try: self.color_trigger = rule.get("color_trigger", util.RE_COLOR_START) except Exception: diff --git a/ch_native_picker.py b/ch_native_picker.py index e0934cab..5173cc04 100644 --- a/ch_native_picker.py +++ b/ch_native_picker.py @@ -1,7 +1,6 @@ """OS specific color pickers.""" import sublime import subprocess -from .lib.coloraide import Color import time MAC_CHOOSE_COLOR = '''\ @@ -40,6 +39,7 @@ class _ColorPicker: def __init__(self, color): """Initialize the color.""" + self.base = type(color) self.color = color def pick(self): @@ -55,7 +55,7 @@ def pick(self): """Pick the color.""" color = self.color.convert('srgb') - coords = [x * UINT for x in color.fit(in_place=True).coords()] + coords = [x * UINT for x in color.clone().fit()[:-1]] try: p = subprocess.Popen( ['osascript', '-e', MAC_CHOOSE_COLOR.format(*coords)], @@ -67,7 +67,7 @@ def pick(self): if returncode: color = None else: - color = Color("srgb", [int(x) / UINT for x in out[0].split(b', ')]) + color = self.base("srgb", [int(x) / UINT for x in out[0].split(b', ')]) self.color = color except Exception: color = None @@ -95,7 +95,7 @@ def get_win_pick_colors(self): colors.extend(['color(srgb 1 1 1)'] * delta) for index, color in enumerate(colors, 0): try: - hx = Color(color).to_string(hex=True, alpha=False)[1:] + hx = self.base(color).to_string(hex=True, alpha=False)[1:] colors[index] = int(hx[4:6] + hx[2:4] + hx[0:2], 16) except Exception: colors[index] = 0xffffff @@ -108,7 +108,7 @@ def set_win_pick_colors(self, colors): for index in range(16): hx = '{:06x}'.format(colors[index]) pcolors.append( - Color('rgb({} {} {})'.format(int(hx[4:6], 16), int(hx[2:4], 16), int(hx[0:2], 16))).to_string( + self.base('rgb({} {} {})'.format(int(hx[4:6], 16), int(hx[2:4], 16), int(hx[0:2], 16))).to_string( color=True, fit=False, precision=-1 ) ) @@ -132,7 +132,7 @@ def pick(self): time.sleep(1) if ChooseColorW(ctypes.pointer(picker)): hx = '{:06x}'.format(picker.rgbResult) - color = Color('rgb({} {} {})'.format(int(hx[4:6], 16), int(hx[2:4], 16), int(hx[0:2], 16))) + color = self.base('rgb({} {} {})'.format(int(hx[4:6], 16), int(hx[2:4], 16), int(hx[0:2], 16))) self.color = color else: color = None @@ -163,7 +163,7 @@ def pick(self): if returncode: color = None else: - color = Color(out[0].decode('utf-8').strip()) + color = self.base(out[0].decode('utf-8').strip()) self.color = color except Exception: color = None diff --git a/ch_panel.py b/ch_panel.py index a4e89d33..ed6cf6d3 100755 --- a/ch_panel.py +++ b/ch_panel.py @@ -9,7 +9,6 @@ import mdpopups from .lib import colorbox from html.parser import HTMLParser -from .lib.coloraide import Color from .lib.coloraide import __version_info__ as color_ver from .ch_native_picker import pick as native_picker from .ch_mixin import _ColorMixin @@ -120,7 +119,7 @@ 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) + color = self.base(color).to_string(**util.COLOR_SERIALIZE) if palette_type == '__global__': color_palettes = util.get_palettes() @@ -143,7 +142,7 @@ 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) + color = self.base(color).to_string(**util.COLOR_SERIALIZE) if palette_type == "__special__": if palette_name == 'Favorites': @@ -225,7 +224,7 @@ def add_fav(self, color): """Add favorite.""" favs = util.get_favs()['colors'] - favs.append(Color(color).to_string(**util.COLOR_SERIALIZE)) + favs.append(self.base(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. @@ -235,7 +234,7 @@ def remove_fav(self, color): """Remove favorite.""" favs = util.get_favs()['colors'] - favs.remove(Color(color).to_string(**util.COLOR_SERIALIZE)) + favs.remove(self.base(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. @@ -246,7 +245,7 @@ def color_picker(self, color): if self.os_color_picker: self.view.hide_popup() - new_color = native_picker(Color(color).convert("srgb", fit=True)) + new_color = native_picker(self.base(color).convert("srgb", fit=True)) if new_color is not None: sublime.set_timeout( lambda c=new_color.to_string(**util.COLOR_FULL_PREC): self.view.run_command( @@ -286,14 +285,14 @@ def show_tool(self, tool, raw=None): is_color_mod = mod_name == "ColorHelper.custom.st_colormod.Color" if raw is None: - color = Color(obj.color).to_string(**util.DEFAULT) + color = self.base(obj.color).to_string(**util.DEFAULT) current = self.view.substr(sublime.Region(obj.start, obj.end)) elif tool == '__colormod__': # We need this unaltered color = raw current = color else: - color = Color(raw).to_string(**util.DEFAULT) + color = self.base(raw).to_string(**util.DEFAULT) current = color if tool == "__edit__": @@ -368,7 +367,7 @@ def format_palettes(self, color_list, label, palette_type, caption=None, color=N color_box = [] for color in color_list[:5]: - c = Color(color) + c = self.base(color) preview = self.get_preview(c) color_box.append(preview.preview2) @@ -394,7 +393,7 @@ def format_colors(self, color_list, label, palette_type, delete=None): width = self.width * 2 check_size = self.check_size(height) for f in color_list: - color = Color(f) + color = self.base(f) if count != 0 and (count % 8 == 0): colors.append('\n\n') elif count != 0: @@ -494,7 +493,7 @@ def show_insert(self, color, dialog_type, palette_name=None, update=False, raw=N """Show insert panel.""" original = color - color = Color(color) + color = self.base(color) sels = self.view.sel() if color is not None and len(sels) == 1: @@ -579,7 +578,7 @@ def show_palettes(self, delete=False, color=None, update=False): project_palettes = util.get_project_palettes(self.view.window()) template_vars = { - "color": (Color(color if color else '#ffffffff').to_string(**util.DEFAULT)), + "color": (self.base(color if color else '#ffffffff').to_string(**util.DEFAULT)), "show_add_option": cursor_color is not None, "generic_color": cursor_color.color.to_string(**util.COLOR_FULL_PREC) if cursor_color is not None else '', "show_picker_menu": show_picker, @@ -709,7 +708,7 @@ def get_cursor_color(self): if obj is not None: obj.start = region.begin() obj.end = region.end() - obj.color = Color(obj.color) + obj.color = self.base(obj.color) return obj def show_color_info(self, update=False): @@ -752,6 +751,7 @@ def show_color_info(self, update=False): def run(self, edit, mode, palette_name=None, color=None, insert_raw=None, result_type=None): """Run the specified tooltip.""" + self.base = util.get_base_color() self.setup_gamut_style() self.setup_image_border() self.setup_sizes() @@ -771,7 +771,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.EXTENDED_SRGB_SPACES).to_string(**util.COLOR_FULL_PREC) + color = self.base("white", filters=util.EXTENDED_SRGB_SPACES).to_string(**util.COLOR_FULL_PREC) else: color = obj.color.to_string(**util.COLOR_FULL_PREC) self.color_picker(color) @@ -788,8 +788,10 @@ def is_enabled(self, mode, **kwargs): try: # This will throw an exception if there is no rule associated with the view. # Can't enable if the view has no color rules. + self.base = util.get_base_color() self.setup_color_class() except Exception: + print('what?') return False s = sublime.load_settings('ColorHelper.sublime-settings') diff --git a/ch_picker.py b/ch_picker.py index 7bd13767..8bf9b134 100644 --- a/ch_picker.py +++ b/ch_picker.py @@ -8,7 +8,6 @@ import sublime_plugin import mdpopups from .lib import colorbox -from .lib.coloraide import Color from .lib.coloraide import util as cutil from .lib.coloraide import algebra as alg from .lib.coloraide.css import color_names as css_names @@ -61,7 +60,7 @@ def setup(self, color, mode, controls, on_done, on_cancel): self.on_done = on_done self.on_cancel = on_cancel self.template_vars = {} - color = Color(color) + color = self.base(color) self.setup_gamut_style() self.setup_image_border() self.setup_sizes() @@ -70,13 +69,13 @@ def setup(self, color, mode, controls, on_done, on_cancel): self.setup_controls(controls) color.convert(self.mode, in_place=True) if not color.in_gamut(): - color.fit(in_place=True) + color.fit() else: - color.clip(in_place=True) + color.clip() # Ensure hue is between 0 - 360. self.color = color if self.color.space() != "srgb" and not self.color.is_nan("hue"): - self.color.hue = self.color.hue % 360 + self.color['hue'] = self.color['hue'] % 360 def setup_mode(self, color, specified): """Setup mode.""" @@ -110,7 +109,7 @@ def get_color_map_square_hsv(self, mode='hsv'): global default_border global color_scale - hue, saturation, value = alg.no_nans(self.color.convert(mode).coords()) + hue, saturation, value = alg.no_nans(self.color.convert(mode)[:-1]) r_sat = saturation r_val = value @@ -133,19 +132,19 @@ def get_color_map_square_hsv(self, mode='hsv'): # Generate the colors with each row being darker than the last. # Each column will progress through hues. - color = Color(mode, [r_hue, 0, 1], filters=util.EXTENDED_SRGB_SPACES) + color = self.base(mode, [r_hue, 0, 1], filters=util.EXTENDED_SRGB_SPACES) if color.is_nan("hue"): - color.hue = 0.0 + color['hue'] = 0.0 check_size = self.check_size(self.height) for y in range(0, 17): html_colors.append([]) for x in range(0, 17): - this_sat = abs(color.saturation - r_sat) < 0.03125 - this_val = abs(color.value - r_val) < 0.03125 + this_sat = abs(color['saturation'] - r_sat) < 0.03125 + this_val = abs(color['value'] - r_val) < 0.03125 border_color = self.default_border if this_sat and this_val: lum = color.luminance() - border_color = Color(self.gamut_space, [1, 1, 1] if lum < 0.5 else [0, 0, 0]) + border_color = self.base(self.gamut_space, [1, 1, 1] if lum < 0.5 else [0, 0, 0]) value = color.convert(self.gamut_space) kwargs = { "border_size": BORDER_SIZE, "height": self.height, "width": self.width, @@ -183,16 +182,16 @@ def get_color_map_square_hsv(self, mode='hsv'): ) ) ) - color.saturation = min(color.saturation + 0.0625, 1) - color.hue = r_hue - color.value = max(color.value - 0.0625, 0) - color.saturation = 0 - color.hue = r_hue + color['saturation'] = min(color['saturation'] + 0.0625, 1) + color['hue'] = r_hue + color['value'] = max(color['value'] - 0.0625, 0) + color['saturation'] = 0 + color['hue'] = r_hue # Generate a hue bar. - color = Color(mode, [0, 1, 1], filters=util.EXTENDED_SRGB_SPACES) + color = self.base(mode, [0, 1, 1], filters=util.EXTENDED_SRGB_SPACES) if color.is_nan("hue"): - color.hue = 0.0 + color['hue'] = 0.0 check_size = self.check_size(self.height) for y in range(0, 17): value = color.convert(self.gamut_space) @@ -200,11 +199,11 @@ def get_color_map_square_hsv(self, mode='hsv'): "border_size": BORDER_SIZE, "height": self.height, "width": self.width, "check_size": check_size } - this_hue = color.hue == r_hue + this_hue = color['hue'] == r_hue border_color = self.default_border if this_hue: lum = color.luminance() - border_color = Color(self.gamut_space, [1, 1, 1] if lum < 0.5 else [0, 0, 0]) + border_color = self.base(self.gamut_space, [1, 1, 1] if lum < 0.5 else [0, 0, 0]) if this_hue: border_map = colorbox.TOP | colorbox.LEFT | colorbox.BOTTOM | colorbox.RIGHT @@ -216,8 +215,8 @@ def get_color_map_square_hsv(self, mode='hsv'): border_map = colorbox.LEFT | colorbox.RIGHT kwargs["border_map"] = border_map - color.value = r_val - color.saturation = r_sat + color['value'] = r_val + color['saturation'] = r_sat html_colors[y].append( '{}'.format( color.to_string(**COLOR_FULL_PREC), @@ -227,9 +226,9 @@ def get_color_map_square_hsv(self, mode='hsv'): ) ) ) - color.hue = color.hue + 22.4375 - color.value = 1 - color.saturation = 1 + color['hue'] = color['hue'] + 22.4375 + color['value'] = 1 + color['saturation'] = 1 color_map = ( ''.join(['{}
'.format(''.join([y1 for y1 in x1])) for x1 in html_colors]) @@ -245,7 +244,7 @@ def get_color_map_square(self, mode='hsl'): global default_border global color_scale - hue, saturation, lightness = alg.no_nans(self.color.convert(mode).coords()) + hue, saturation, lightness = alg.no_nans(self.color.convert(mode)[:-1]) r_sat = saturation r_lit = lightness @@ -264,21 +263,21 @@ def get_color_map_square(self, mode='hsl'): # Generate the colors with each row being darker than the last. # Each column will progress through hues. - color = Color(mode, [0, 1 * scale, lightness], filters=util.EXTENDED_SRGB_SPACES) + color = self.base(mode, [0, 1 * scale, lightness], filters=util.EXTENDED_SRGB_SPACES) if color.is_nan("hue"): - color.hue = 0.0 + color['hue'] = 0.0 check_size = self.check_size(self.height) for y in range(0, 17): html_colors.append([]) for x in range(0, 17): this_hue = False - this_sat = abs(color.saturation - r_sat) < (0.03125 * scale) + this_sat = abs(color['saturation'] - r_sat) < (0.03125 * scale) border_color = self.default_border if this_sat: - this_hue = abs(color.hue - hue) < 11.21875 + this_hue = abs(color['hue'] - hue) < 11.21875 if this_hue: lum = color.luminance() - border_color = Color( + border_color = self.base( self.gamut_space, [1 * scale, 1 * scale, 1 * scale] if lum < 0.5 else [0, 0, 0] ) value = color.convert(self.gamut_space) @@ -318,14 +317,14 @@ def get_color_map_square(self, mode='hsl'): ) ) ) - color.hue = color.hue + 22.4375 - color.hue = 0.0 - color.saturation = color.saturation - (0.0625 * scale) + color['hue'] = color['hue'] + 22.4375 + color['hue'] = 0.0 + color['saturation'] = color['saturation'] - (0.0625 * scale) # Generate a grayscale bar. - color = Color(mode, [hue, saturation, 1 * scale], filters=util.EXTENDED_SRGB_SPACES) + color = self.base(mode, [hue, saturation, 1 * scale], filters=util.EXTENDED_SRGB_SPACES) if color.is_nan("hue"): - color.hue = 0.0 + color['hue'] = 0.0 check_size = self.check_size(self.height) for y in range(0, 17): value = color.convert(self.gamut_space) @@ -333,11 +332,11 @@ def get_color_map_square(self, mode='hsl'): "border_size": BORDER_SIZE, "height": self.height, "width": self.width, "check_size": check_size } - this_lit = abs(color.lightness - r_lit) < (0.03125 * scale) + this_lit = abs(color['lightness'] - r_lit) < (0.03125 * scale) border_color = self.default_border if this_lit: lum = color.luminance() - border_color = Color( + border_color = self.base( self.gamut_space, [1 * scale, 1 * scale, 1 * scale] if lum < 0.5 else [0, 0, 0] ) @@ -360,7 +359,7 @@ def get_color_map_square(self, mode='hsl'): ) ) ) - color.lightness = color.lightness - (0.0625 * scale) + color['lightness'] = color['lightness'] - (0.0625 * scale) color_map = ( ''.join(['{}
'.format(''.join([y1 for y1 in x1])) for x1 in html_colors]) @@ -391,7 +390,7 @@ def get_css_color_names(self): check_size = self.check_size(self.height) html = [] for name in sorted(css_names.name2val_map): - color = Color(name, filters=util.EXTENDED_SRGB_SPACES) + color = self.base(name, filters=util.EXTENDED_SRGB_SPACES) html.append( '[{}]({}) {}
'.format( @@ -452,10 +451,10 @@ def get_hires_color_channel(self, color_filter, mode='undefined'): color.set(color_filter, x / 255.0) label = label.format(str(x)) elif color_filter == 'alpha': - color.alpha = x / 100.0 + color[-1] = x / 100.0 label = label.format("{:d}%".format(x)) elif color_filter == 'hue': - color.hue = x + color['hue'] = x label = label.format("{:d}\xb0".format(x)) elif color_filter in ('saturation', 'lightness', 'whiteness', 'blackness', 'value'): color.set(color_filter, x / (100 / scale)) @@ -698,7 +697,7 @@ def handle_href(self, href): self.view.run_command( cmd, { - "initial": Color(color, filters=util.EXTENDED_SRGB_SPACES) + "initial": self.base(color, filters=util.EXTENDED_SRGB_SPACES) .convert(convert, in_place=True) .to_string(**DEFAULT), "on_done": on_done, "on_cancel": on_cancel @@ -739,6 +738,7 @@ def run( """Run command.""" # Setup + self.base = util.get_base_color() self.setup(color, mode, controls, on_done, on_cancel) # Show the appropriate dialog diff --git a/ch_preview.py b/ch_preview.py index 9fc12353..9c85b965 100644 --- a/ch_preview.py +++ b/ch_preview.py @@ -6,7 +6,6 @@ """ import sublime import sublime_plugin -from .lib.coloraide import Color import threading from time import time, sleep import re @@ -252,7 +251,10 @@ def get_color_class(self, pt, classes): if class_options is None: continue module = class_options.get("class", "ColorHelper.lib.coloraide.Color") - if isinstance(module, str): + if module == "ColorHelper.lib.coloraide.Color": + color_class = self.base + class_options["class"] = color_class + elif isinstance(module, str): # Initialize the color module and cache it for this view color_class = util.import_color(module) class_options["class"] = color_class @@ -271,8 +273,8 @@ def setup_gamut_options(self): self.gamut_space = ch_settings.get('gamut_space', 'srgb') if self.gamut_space not in util.GAMUT_SPACES: self.gamut_space = 'srgb' - self.out_of_gamut = Color("transparent").convert(self.gamut_space) - self.out_of_gamut_border = Color(self.view.style().get('redish', "red")).convert(self.gamut_space) + self.out_of_gamut = self.base("transparent").convert(self.gamut_space) + self.out_of_gamut_border = self.base(self.view.style().get('redish', "red")).convert(self.gamut_space) def do_search(self, force=False): """ @@ -415,14 +417,14 @@ def do_search(self, force=False): continue # Calculate a reasonable border color for our image at this location and get color strings - hsl = Color( + hsl = self.base( mdpopups.scope2style(self.view, self.view.scope_name(pt))['background'], filters=util.CSS_SRGB_SPACES ).convert("hsl") - hsl.lightness = hsl.lightness + (0.3 if hsl.luminance() < 0.5 else -0.3) + hsl['lightness'] = hsl['lightness'] + (0.3 if hsl.luminance() < 0.5 else -0.3) preview_border = hsl.convert(self.gamut_space, fit=True).set('alpha', 1) - color = Color(obj.color) + color = self.base(obj.color) title = '' if self.gamut_space == 'srgb': check_space = self.gamut_space if color.space() not in util.SRGB_SPACES else color.space() @@ -508,6 +510,7 @@ def erase_phantoms(self): def run(self, clear=False, force=False): """Run.""" + self.base = util.get_base_color() self.view = self.window.active_view() ids = set([view.buffer_id() for view in self.window.views()]) keys = set(self.previews.keys()) diff --git a/ch_tool_blend.py b/ch_tool_blend.py index 9ba558c7..16acad57 100644 --- a/ch_tool_blend.py +++ b/ch_tool_blend.py @@ -1,7 +1,6 @@ """Color edit tool.""" import sublime import sublime_plugin -from .lib.coloraide import Color import mdpopups from .lib import colorbox from . import ch_util as util @@ -36,7 +35,7 @@ """ -def parse_color(string, start=0, second=False): +def parse_color(base, string, start=0, second=False): """ Parse colors. @@ -51,7 +50,7 @@ def parse_color(string, start=0, second=False): space = None blend_mode = 'normal' # First color - color = Color.match(string, start=start, fullmatch=False) + color = base.match(string, start=start, fullmatch=False) if color: start = color.end if color.end != length: @@ -98,7 +97,7 @@ def parse_color(string, start=0, second=False): return color, more, space, blend_mode -def evaluate(string): +def evaluate(base, string): """Evaluate color.""" colors = [] @@ -110,12 +109,12 @@ def evaluate(string): space = None # Try to capture the color or the two colors to mix - first, more, space, blend_mode = parse_color(color) + first, more, space, blend_mode = parse_color(base, color) if first and more is not None: if more is False: first = None else: - second, more, space, blend_mode = parse_color(color, start=first.end, second=True) + second, more, space, blend_mode = parse_color(base, color, start=first.end, second=True) if not second or more is False: first = None second = None @@ -162,7 +161,7 @@ def initial_text(self): except Exception: pass if color is not None: - color = Color(color) + color = self.base(color) return color.to_string(**util.DEFAULT) return '' @@ -172,11 +171,11 @@ def preview(self, text): style = self.get_html_style() try: - colors = evaluate(text) + colors = evaluate(self.base, text) html = "" for color in colors: - pcolor = Color(color) + pcolor = self.base(color) message = "" color_string = "" if self.gamut_space == 'srgb': @@ -184,7 +183,7 @@ def preview(self, text): else: check_space = self.gamut_space if not pcolor.in_gamut(check_space): - pcolor.fit(self.gamut_space, in_place=True) + pcolor.fit(self.gamut_space) message = '
* preview out of gamut' color_string = "Gamut Mapped: {}
".format(pcolor.to_string()) pcolor.convert(self.gamut_space, fit=True, in_place=True) @@ -192,7 +191,7 @@ def preview(self, text): preview = pcolor.clone().set('alpha', 1) preview_alpha = pcolor preview_border = self.default_border - temp = Color(preview_border) + temp = self.base(preview_border) if temp.luminance() < 0.5: second_border = temp.mix('white', 0.25, space=self.gamut_space, out_space=self.gamut_space) second_border.set('alpha', 1) @@ -226,7 +225,7 @@ def validate(self, color): """Validate.""" try: - color = evaluate(color) + color = evaluate(self.base, color) return len(color) > 0 except Exception: return False @@ -240,7 +239,8 @@ def run( ): """Run command.""" - colors = evaluate(color_helper_blend_mode) + self.base = util.get_base_color() + colors = evaluate(color_helper_blend_mode, self.base) color = None if colors: color = colors[-1] diff --git a/ch_tool_colormod.py b/ch_tool_colormod.py index 52ce7ec0..1d6c4bae 100644 --- a/ch_tool_colormod.py +++ b/ch_tool_colormod.py @@ -1,7 +1,6 @@ """ColorMod tool.""" import sublime import sublime_plugin -from .lib.coloraide import Color import mdpopups from .lib import colorbox from . import ch_util as util @@ -94,7 +93,7 @@ def preview(self, text): html = None color = self.color_mod_class(text.strip()) if color is not None: - pcolor = Color(color) + pcolor = self.base(color) preview_border = self.default_border message = "" if self.gamut_space == 'srgb': @@ -107,7 +106,7 @@ def preview(self, text): preview = pcolor.clone().set('alpha', 1) preview_alpha = pcolor preview_border = self.default_border - temp = Color(preview_border) + temp = self.base(preview_border) if temp.luminance() < 0.5: second_border = temp.mix('white', 0.25, space=self.gamut_space, out_space=self.gamut_space) second_border.set('alpha', 1) @@ -153,6 +152,7 @@ def run( ): """Run command.""" + self.base = util.get_base_color() text = color_helper_color_mod.strip() self.custom_color_class = util.import_color("ColorHelper.custom.st_colormod.Color") color = self.custom_color_class(text) diff --git a/ch_tool_contrast.py b/ch_tool_contrast.py index 6b826509..268e55b1 100644 --- a/ch_tool_contrast.py +++ b/ch_tool_contrast.py @@ -1,7 +1,6 @@ """Color contrast tool.""" import sublime import sublime_plugin -from .lib.coloraide import Color import mdpopups from . import ch_util as util from .ch_mixin import _ColorMixin @@ -42,7 +41,7 @@ """ -def parse_color(string, start=0, second=False): +def parse_color(base, string, start=0, second=False): """ Parse colors. @@ -56,7 +55,7 @@ def parse_color(string, start=0, second=False): more = None ratio = None # First color - color = Color.match(string, start=start, fullmatch=False) + color = base.match(string, start=start, fullmatch=False) if color: start = color.end if color.end != length: @@ -84,7 +83,7 @@ def parse_color(string, start=0, second=False): return color, ratio, more -def evaluate(string): +def evaluate(base, string): """Evaluate color.""" colors = [] @@ -95,12 +94,12 @@ def evaluate(string): ratio = None # Try to capture the color or the two colors to mix - first, ratio, more = parse_color(color) + first, ratio, more = parse_color(base, color) if first and more is not None: if more is False: first = None else: - second, ratio, more = parse_color(color, start=first.end, second=True) + second, ratio, more = parse_color(base, color, start=first.end, second=True) if not second or more is False: first = None second = None @@ -111,20 +110,20 @@ def evaluate(string): else: if first: first = first.color - second = Color("white" if first.luminance() < 0.5 else "black") + second = base("white" if first.luminance() < 0.5 else "black") # Package up the color, or the two reference colors along with the mixed. if first: - colors.append(first.fit('srgb', in_place=True)) + colors.append(first.fit('srgb')) if second: - if second.alpha < 1.0: - second.alpha = 1.0 - colors.append(second.fit('srgb', in_place=True)) + if second[-1] < 1.0: + second[-1] = 1.0 + colors.append(second.fit('srgb')) if ratio: - if first.alpha < 1.0: + if first[-1] < 1.0: first = first.compose(second, space="srgb") - hwb_fg = first.convert('hwb').clip(in_place=True) - hwb_bg = second.convert('hwb').clip(in_place=True) + hwb_fg = first.convert('hwb').clip() + hwb_bg = second.convert('hwb').clip() first.update(hwb_fg) second.update(hwb_bg) @@ -136,10 +135,10 @@ def evaluate(string): ratio ) ) - first.update(Color(color)) + first.update(base(color)) colors[0] = first - if first.alpha < 1.0: + if first[-1] < 1.0: # Contrasted with current color colors.append(first.compose(second, space="srgb")) # Contrasted with the two extremes min and max @@ -147,7 +146,8 @@ def evaluate(string): colors.append(first.compose("black", space="srgb")) else: colors.append(first) - except Exception: + except Exception as e: + print(e) colors = [] return colors @@ -181,7 +181,7 @@ def initial_text(self): except Exception: pass if color is not None: - color = Color(color) + color = self.base(color) return color.to_string(**util.DEFAULT) return '' @@ -191,7 +191,7 @@ def preview(self, text): style = self.get_html_style() try: - colors = evaluate(text) + colors = evaluate(self.base, text) html = mdpopups.md2html(self.view, DEF_RATIO.format(style)) if len(colors) >= 3: lum2 = colors[1].luminance() @@ -224,16 +224,20 @@ def preview(self, text): colors[1].convert('srgb').clip().to_string(**util.COMMA) ) return sublime.Html(style + html) - except Exception: + except Exception as e: + print('huh?') + print(e) return sublime.Html(mdpopups.md2html(self.view, DEF_RATIO.format(style))) def validate(self, color): """Validate.""" try: - colors = evaluate(color) + colors = evaluate(self.base, color) return len(colors) > 0 - except Exception: + except Exception as e: + print('what?') + print(e) return False @@ -245,7 +249,8 @@ def run( ): """Run command.""" - colors = evaluate(color_helper_contrast_ratio) + self.base = util.get_base_color() + colors = evaluate(self.base, color_helper_contrast_ratio) color = None if colors: color = colors[0] diff --git a/ch_tool_diff.py b/ch_tool_diff.py index 212091c0..78f472d8 100644 --- a/ch_tool_diff.py +++ b/ch_tool_diff.py @@ -1,7 +1,6 @@ """Color difference tool.""" import sublime import sublime_plugin -from .lib.coloraide import Color from .lib import colorbox import mdpopups from . import ch_util as util @@ -32,7 +31,7 @@ """ -def parse_color(string, start=0, second=False): +def parse_color(base, string, start=0, second=False): """ Parse colors. @@ -46,7 +45,7 @@ def parse_color(string, start=0, second=False): more = None method = None # First color - color = Color.match(string, start=start, fullmatch=False) + color = base.match(string, start=start, fullmatch=False) if color: start = color.end if color.end != length: @@ -74,7 +73,7 @@ def parse_color(string, start=0, second=False): return color, method, more -def evaluate(string): +def evaluate(base, string): """Evaluate color.""" colors = [] @@ -85,12 +84,12 @@ def evaluate(string): method = None # Try to capture the color or the two colors diff - first, method, more = parse_color(color) + first, method, more = parse_color(base, color) if first and more is not None: if more is False: first = None else: - second, method, more = parse_color(color, start=first.end, second=True) + second, method, more = parse_color(base, color, start=first.end, second=True) if not second or more is False: first = None second = None @@ -159,7 +158,7 @@ def initial_text(self): except Exception: pass if color is not None: - color = Color(color) + color = self.base(color) colors.append(color.to_string(**util.DEFAULT)) if len(texts) == len(colors): return ' - '.join(colors) @@ -171,13 +170,13 @@ def preview(self, text): style = self.get_html_style() try: - colors, delta = evaluate(text) + colors, delta = evaluate(self.base, text) if not colors: raise ValueError('No colors') html = mdpopups.md2html(self.view, DEF_DIFF.format(style)) html = "" for color in colors: - orig = Color(color) + orig = self.base(color) message = "" color_string = "" if self.gamut_space == 'srgb': @@ -185,7 +184,7 @@ def preview(self, text): else: check_space = self.gamut_space if not orig.in_gamut(check_space): - orig.fit(self.gamut_space, in_place=True) + orig.fit(self.gamut_space) message = '
* preview out of gamut' color_string = "Gamut Mapped: {}
".format(orig.to_string()) orig.convert(self.gamut_space, fit=True, in_place=True) @@ -193,7 +192,7 @@ def preview(self, text): preview = orig.clone().set('alpha', 1) preview_alpha = orig preview_border = self.default_border - temp = Color(preview_border) + temp = self.base(preview_border) if temp.luminance() < 0.5: second_border = temp.mix('white', 0.25, space=self.gamut_space, out_space=self.gamut_space) second_border.set('alpha', 1) @@ -224,7 +223,7 @@ def validate(self, color): """Validate.""" try: - colors, _ = evaluate(color) + colors, _ = evaluate(self.base, color) return len(colors) > 0 except Exception: return False @@ -238,7 +237,8 @@ def run( ): """Run command.""" - colors, _ = evaluate(color_helper_difference) + self.base = util.get_base_color() + colors, _ = evaluate(self.base, color_helper_difference) color = None if colors: color = colors[0] diff --git a/ch_tool_edit.py b/ch_tool_edit.py index f66627b5..a8044895 100644 --- a/ch_tool_edit.py +++ b/ch_tool_edit.py @@ -1,7 +1,6 @@ """Color edit tool.""" import sublime import sublime_plugin -from .lib.coloraide import Color import mdpopups from .lib import colorbox from . import ch_util as util @@ -38,7 +37,7 @@ """ -def parse_color(string, start=0, second=False): +def parse_color(base, string, start=0, second=False): """ Parse colors. @@ -53,7 +52,7 @@ def parse_color(string, start=0, second=False): percent = None space = None # First color - color = Color.match(string, start=start, fullmatch=False) + color = base.match(string, start=start, fullmatch=False) if color: start = color.end if color.end != length: @@ -97,7 +96,7 @@ def parse_color(string, start=0, second=False): return color, percent, more, space -def evaluate(string): +def evaluate(base, string): """Evaluate color.""" colors = [] @@ -109,13 +108,13 @@ def evaluate(string): space = None # Try to capture the color or the two colors to mix - first, percent1, more, space = parse_color(color) + first, percent1, more, space = parse_color(base, color) if first and more is not None: percent2 = None if more is False: first = None else: - second, percent2, more, space = parse_color(color, start=first.end, second=True) + second, percent2, more, space = parse_color(base, color, start=first.end, second=True) if not second or more is False: first = None second = None @@ -199,7 +198,7 @@ def initial_text(self): except Exception: pass if color is not None: - color = Color(color) + color = self.base(color) return color.to_string(**util.DEFAULT) return '' @@ -209,11 +208,11 @@ def preview(self, text): style = self.get_html_style() try: - colors = evaluate(text) + colors = evaluate(self.base, text) html = "" for color in colors: - pcolor = Color(color) + pcolor = self.base(color) message = "" color_string = "" if self.gamut_space == 'srgb': @@ -221,7 +220,7 @@ def preview(self, text): else: check_space = self.gamut_space if not pcolor.in_gamut(check_space): - pcolor.fit(self.gamut_space, in_place=True) + pcolor.fit(self.gamut_space) message = '
* preview out of gamut' color_string = "Gamut Mapped: {}
".format(pcolor.to_string()) pcolor.convert(self.gamut_space, fit=True, in_place=True) @@ -229,7 +228,7 @@ def preview(self, text): preview = pcolor.clone().set('alpha', 1) preview_alpha = pcolor preview_border = self.default_border - temp = Color(preview_border) + temp = self.base(preview_border) if temp.luminance() < 0.5: second_border = temp.mix('white', 0.25, space=self.gamut_space, out_space=self.gamut_space) second_border.set('alpha', 1) @@ -263,7 +262,7 @@ def validate(self, color): """Validate.""" try: - color = evaluate(color) + color = evaluate(self.base, color) return len(color) > 0 except Exception: return False @@ -277,7 +276,8 @@ def run( ): """Run command.""" - colors = evaluate(color_helper_edit) + self.base = util.get_base_color() + colors = evaluate(self.base, color_helper_edit) color = None if colors: color = colors[-1] diff --git a/ch_tools.py b/ch_tools.py index 8ec66590..240ca500 100644 --- a/ch_tools.py +++ b/ch_tools.py @@ -1,7 +1,6 @@ """Color Helper tools.""" import sublime import sublime_plugin -from .lib.coloraide import Color from . import ch_util as util from .ch_mixin import _ColorMixin import re @@ -54,6 +53,7 @@ class _ColorInputHandler(_ColorMixin, sublime_plugin.TextInputHandler): def __init__(self, view, on_cancel=None, **kwargs): """Initialize.""" + self.base = util.get_base_color() self.view = view self.on_cancel = on_cancel self.setup_gamut_style() @@ -76,7 +76,7 @@ def get_html_style(self): styles = self.view.style() fg = styles['foreground'] bg = styles['background'] - temp = Color(bg).convert("srgb") + temp = self.base(bg).convert("srgb") is_dark = temp.luminance() < 0.5 bg = temp.mix("white" if is_dark else "black", 0.05, space="srgb").to_string(**util.HEX) code = temp.mix("white" if is_dark else "black", 0.15, space="srgb").to_string(**util.HEX) diff --git a/ch_util.py b/ch_util.py index ea5204f8..2baaa18b 100644 --- a/ch_util.py +++ b/ch_util.py @@ -10,9 +10,16 @@ import mdpopups import base64 import importlib +from .lib.coloraide import Color as Base +from .lib.coloraide.color import SUPPORTED_SPACES from .lib.coloraide.css.parse import RE_COLOR_MATCH -from .lib.coloraide import Color from .lib.coloraide import __version_info__ as coloraide_version +import functools + + +class Color(Base): + """Custom base.""" + PALETTE_CONFIG = 'ColorHelper.palettes' REQUIRED_COLOR_VERSION = (0, 1, 0, 'alpha', 19) @@ -169,6 +176,19 @@ ] +@functools.lru_cache() +def get_base_color(): + """Get base color.""" + + Color.deregister('space:*') + Color.register(SUPPORTED_SPACES) + settings = sublime.load_settings("color_helper.sublime-settings") + spaces = settings.get('add_to_default_spaces', []) + for space in spaces: + Color.register(import_color(space)) + return Color + + def import_color(module_path): """Import color module.""" @@ -248,6 +268,7 @@ def get_scope(view, rules, skip_sel_check=False): def update_colors_2_0(colors): """Update colors for version 2.0.""" + base = get_base_color() new_colors = [] for c in colors: try: @@ -267,7 +288,7 @@ def update_colors_2_0(colors): elif space == '--xyz-d65': space = 'xyz-d65' - new_colors.append(Color(space.lstrip('-'), channels, alpha).to_string(**COLOR_SERIALIZE)) + new_colors.append(base(space.lstrip('-'), channels, alpha).to_string(**COLOR_SERIALIZE)) else: new_colors.append(c) except Exception: @@ -278,6 +299,7 @@ def update_colors_2_0(colors): def update_colors_1_0(colors): """Update colors for version 1.0.""" + base = get_base_color() new_colors = [] for c in colors: try: @@ -293,9 +315,9 @@ def update_colors_1_0(colors): alpha = float(values[1]) else: alpha = 1 - new_colors.append(Color(space.lstrip('-'), channels, alpha).to_string(**COLOR_SERIALIZE)) + new_colors.append(base(space.lstrip('-'), channels, alpha).to_string(**COLOR_SERIALIZE)) else: - new_colors.append(Color(c).to_string(**COLOR_SERIALIZE)) + new_colors.append(base(c).to_string(**COLOR_SERIALIZE)) except Exception: pass return new_colors diff --git a/custom/ahex.py b/custom/ahex.py index 7336876c..17a2ec98 100644 --- a/custom/ahex.py +++ b/custom/ahex.py @@ -1,9 +1,9 @@ """Custon color that looks for colors of format `#RRGGBBAA` as `#AARRGGBB`.""" -from ..lib.coloraide import Color from ..lib.coloraide.spaces.srgb.css import SRGB from ..lib.coloraide.css import parse from ..lib.coloraide import algebra as alg import re +from ColorHelper.ch_util import get_base_color MATCH = re.compile(r"(?i)\#(?:{hex}{{8}}|{hex}{{6}})\b".format(**parse.COLOR_PARTS)) @@ -45,13 +45,14 @@ def match(cls, string, start=0, fullmatch=True): return split_channels(m.group(0)), m.end(0) return None + @classmethod def to_string( - self, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs + cls, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs ): """Convert to Hex format.""" options = kwargs - a = alg.no_nan(self.alpha) + a = alg.no_nan(parent[-1]) show_alpha = alpha is not False and (alpha is True or a < 1.0) template = "#{:02x}{:02x}{:02x}{:02x}" if show_alpha else "#{:02x}{:02x}{:02x}" @@ -60,7 +61,7 @@ def to_string( # Always fit hex method = None if not isinstance(fit, str) else fit - coords = alg.no_nans(parent.fit(method=method).coords()) + coords = alg.no_nans(parent.clone().fit(method=method)[:-1]) if show_alpha: value = template.format( int(alg.round_half_up(a * 255.0)), @@ -77,7 +78,7 @@ def to_string( return value -class ColorAlphaHex(Color): +class ColorAlphaHex(get_base_color()): """Color object whose sRGB color space looks for colors of format `#RRGGBBAA` as `#AARRGGBB`.""" diff --git a/custom/ass_abgr.py b/custom/ass_abgr.py index 9956460a..068844b6 100644 --- a/custom/ass_abgr.py +++ b/custom/ass_abgr.py @@ -1,9 +1,9 @@ """Custom color that looks for colors of format `&HAABBGGRR` as `#AARRGGBB`.""" -from ..lib.coloraide import Color from ..lib.coloraide import algebra as alg from ..lib.coloraide.css import parse from ..lib.coloraide.spaces.srgb.css import SRGB import re +from ColorHelper.ch_util import get_base_color MATCH = re.compile(r"(?P&H)?(?P[0-9a-fA-F]{1,8})(?P&|\b)") @@ -41,11 +41,12 @@ def match(cls, string: str, start: int = 0, fullmatch: bool = True): return split_channels(m.group("color")), m.end(0) return None - def to_string(self, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs): + @classmethod + def to_string(cls, parent, *, options=None, alpha=None, precision=None, fit=True, none=False, **kwargs): """Convert color to `&HAABBGGRR`.""" options = kwargs - a = alg.no_nan(self.alpha) + a = alg.no_nan(parent[-1]) 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}" @@ -54,7 +55,7 @@ def to_string(self, parent, *, options=None, alpha=None, precision=None, fit=Tru # Always fit hex method = None if not isinstance(fit, str) else fit - coords = alg.no_nans(parent.fit(method=method).coords()) + coords = alg.no_nans(parent.clone().fit(method=method)[:-1]) if show_alpha: value = template.format( int(alg.round_half_up(a * 255.0)), @@ -71,7 +72,7 @@ def to_string(self, parent, *, options=None, alpha=None, precision=None, fit=Tru return value -class ColorAssABGR(Color): +class ColorAssABGR(get_base_color()): """Color class for ASS `ABGR` colors.""" diff --git a/custom/hex_0x.py b/custom/hex_0x.py index fabbd7c7..63b89429 100644 --- a/custom/hex_0x.py +++ b/custom/hex_0x.py @@ -1,8 +1,8 @@ """Custon color that looks for colors of format `#RRGGBBAA` as `#AARRGGBB`.""" -from ..lib.coloraide import Color from ..lib.coloraide.spaces.srgb.css import SRGB from ..lib.coloraide.css import parse, serialize import re +from ColorHelper.ch_util import get_base_color MATCH = re.compile(r"\b0x(?:[0-9a-fA-f]{8}|[0-9a-fA-f]{6})\b") @@ -19,8 +19,9 @@ def match(cls, string, start=0, fullmatch=True): return parse.parse_hex(m.group(0).replace('0x', '#', 1)), m.end(0) return None + @classmethod def to_string( - self, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs + cls, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs ): """Convert to CSS.""" @@ -36,7 +37,7 @@ def to_string( return h.replace('#', '0x', 1) -class ColorHex(Color): +class ColorHex(get_base_color()): """Color object whose sRGB color space looks for colors of format `#RRGGBBAA` as `#AARRGGBB`.""" diff --git a/custom/st_colormod.py b/custom/st_colormod.py index 138932ab..eee33ae1 100644 --- a/custom/st_colormod.py +++ b/custom/st_colormod.py @@ -1,14 +1,15 @@ """Color-mod.""" import re -from ..lib.coloraide import Color as ColorCSS from ..lib.coloraide import ColorMatch from ..lib.coloraide.css import parse, serialize from ..lib.coloraide import util from ..lib.coloraide import algebra as alg from ..lib.coloraide.spaces.hwb.css import HWB as HWBORIG from collections.abc import Mapping +from itertools import zip_longest as zipl import functools import math +from ColorHelper.ch_util import get_base_color WHITE = [1.0] * 3 BLACK = [0.0] * 3 @@ -188,8 +189,9 @@ def match(cls, string, start=0, fullmatch=True): return parse.parse_channels(string[m.end(1) + 1:m.end(0) - 1], cls.BOUNDS), m.end(0) return None + @classmethod def to_string( - self, + cls, parent, *, alpha=None, @@ -294,7 +296,7 @@ def _adjust(self, string, start=0): if color is not None: self._color = color - self._color.fit(method="clip", in_place=True) + self._color.clone().clip() while not done: m = None @@ -322,7 +324,7 @@ def _adjust(self, string, start=0): else: break - self._color.fit(method="clip", in_place=True) + self._color.clone().clip() else: raise ValueError('Could not calculate base color') except Exception: @@ -344,7 +346,7 @@ def adjust_base(self, base, string): """Adjust base.""" self._color = base - pattern = "color({} {})".format(self._color.fit(method="clip").to_string(precision=-1), string) + pattern = "color({} {})".format(self._color.clone().clip().to_string(precision=-1), string) color, start = self._adjust(pattern) if color is not None: self._color.update(color) @@ -450,7 +452,7 @@ def process_min_contrast(self, m, string, hue): this = self._color.convert("srgb") color2 = color2.convert("srgb") - color2.alpha = 1.0 + color2[-1] = 1.0 self.min_contrast(this, color2, value) self._color.update(this) @@ -524,11 +526,11 @@ def min_contrast(self, color1, color2, target): # Use the best, last values coords = [ - orig.hue, + orig['hue'], last_mix / 100, last_other / 100 ] if is_dark else [ - orig.hue, + orig['hue'], last_other / 100, last_mix / 100 ] @@ -538,7 +540,7 @@ def min_contrast(self, color1, color2, target): # as sRGB will clip off decimals. If we are darkening, then we want to just floor the values as the algorithm # leans more to the light side. rnd = alg.round_half_up if is_dark else math.floor - final = Color("srgb", [rnd(c * 255.0) / 255.0 for c in final.coords()], final.alpha) + final = Color("srgb", [rnd(c * 255.0) / 255.0 for c in final[:-1]], final[-1]) color1.update(final) def blend(self, color, percent, alpha=False, space="srgb"): @@ -554,9 +556,9 @@ def blend(self, color, percent, alpha=False, space="srgb"): if color.space() != space: color.convert(space, in_place=True) - new_color = this.mix(color, percent, space=space) + new_color = this.mix(color, percent, space=space, premultiplied=False) if not alpha: - new_color.alpha = color.alpha + new_color[-1] = color[-1] self._color.update(new_color) def alpha(self, value, op=""): @@ -564,7 +566,7 @@ def alpha(self, value, op=""): this = self._color op = self.OP_MAP.get(op, self._op_null) - this.alpha = op(this.alpha, value) + this[-1] = op(this[-1], value) self._color.update(this) def lightness(self, value, op="", hue=None): @@ -572,9 +574,9 @@ def lightness(self, value, op="", hue=None): this = self._color.convert("hsl") if self._color.space() != "hsl" else self._color if this.is_nan('hue') and hue is not None: - this.hue = hue + this['hue'] = hue op = self.OP_MAP.get(op, self._op_null) - this.lightness = op(this.lightness, value) + this['lightness'] = op(this['lightness'], value) self._color.update(this) def saturation(self, value, op="", hue=None): @@ -582,13 +584,13 @@ def saturation(self, value, op="", hue=None): this = self._color.convert("hsl") if self._color.space() != "hsl" else self._color if this.is_nan("hue") and hue is not None: - this.hue = hue + this['hue'] = hue op = self.OP_MAP.get(op, self._op_null) - this.saturation = op(this.saturation, value) + this['saturation'] = op(this['saturation'], value) self._color.update(this) -class Color(ColorCSS): +class Color(get_base_color()): """Color modify class.""" def __init__(self, color, data=None, alpha=util.DEF_ALPHA, *, filters=None, variables=None, **kwargs): @@ -611,33 +613,37 @@ def _parse( obj = None if isinstance(color, str): + # Parse a color space name and coordinates if data is not None: - s = color.lower() + s = color space_class = cls.CS_MAP.get(s) if space_class and (not filters or s in filters): - num_channels = len(space_class.CHANNEL_NAMES) + num_channels = len(space_class.CHANNELS) if len(data) < num_channels: data = list(data) + [alg.NaN] * (num_channels - len(data)) - obj = space_class(data[:num_channels], alpha) + coords = [alg.clamp(float(v), *c.limit) for c, v in zipl(space_class.CHANNELS, data)] + coords.append(alg.clamp(float(alpha), *space_class.get_channel(-1).limit)) + obj = space_class, coords # Parse a CSS string else: m = cls._match(color, fullmatch=True, filters=filters, variables=variables) if m is None: raise ValueError("'{}' is not a valid color".format(color)) - obj = m[0] - elif isinstance(color, ColorCSS): + coords = [alg.clamp(float(v), *c.limit) for c, v in zipl(m[0].CHANNELS, m[1])] + coords.append(alg.clamp(float(m[2]), *m[0].get_channel(-1).limit)) + obj = m[0], coords + elif isinstance(color, Color): # Handle a color instance if not filters or color.space() in filters: - obj = cls.CS_MAP[color.space()](color._space) + space_class = cls.CS_MAP[color.space()] + obj = space_class, color[:] elif isinstance(color, Mapping): # Handle a color dictionary space = color['space'] - if not filters or space in filters: - cs = cls.CS_MAP[space] - coords = [color[name] for name in cs.CHANNEL_NAMES] - alpha = color.get('alpha', 1) - obj = cs(coords, alpha) + coords = color['coords'] + alpha = color.get('alpha', 1.0) + obj = cls._parse(space, coords, alpha) else: raise TypeError("'{}' is an unrecognized type".format(type(color))) @@ -677,19 +683,25 @@ def _match(cls, string, start=0, fullmatch=False, filters=None, variables=None): string = handle_vars(string, variables) obj, match_end = ColorMod(fullmatch).adjust(string, start) if obj is not None: - return obj._space, start, end if end is not None else match_end + return obj._space, obj[:-1], obj[-1], start, (end if end is not None else match_end) else: return super()._match(string, start, fullmatch) return None @classmethod - def match(cls, string, start=0, fullmatch=False, *, filters=None, variables=None): + def match( + cls, + string: str, + start: int = 0, + fullmatch: bool = False, + *, + filters=None + ): """Match color.""" - m = cls._match(string, start, fullmatch, filters=filters, variables=variables) + m = cls._match(string, start, fullmatch, filters=filters, variables=None) if m is not None: - color = m[0] - return ColorMatch(cls(color.NAME, color.coords(), color.alpha), m[1], m[2]) + return ColorMatch(cls(m[0].NAME, m[1], m[2]), m[3], m[4]) return None def new(self, color, data=None, alpha=util.DEF_ALPHA, *, filters=None, variables=None, **kwargs): @@ -700,18 +712,20 @@ def new(self, color, data=None, alpha=util.DEF_ALPHA, *, filters=None, variables def update(self, color, data=None, alpha=util.DEF_ALPHA, *, filters=None, variables=None, **kwargs): """Update the existing color space with the provided color.""" - c = self._parse(color, data, alpha, filters=filters, variables=variables, **kwargs) space = self.space() - self._space = c - if c.NAME != space: + self._space, self._coords = self._parse( + color, data=data, alpha=alpha, filters=filters, variables=variables, **kwargs + ) + if self._space.NAME != space: self.convert(space, in_place=True) return self def mutate(self, color, data=None, alpha=util.DEF_ALPHA, *, filters=None, variables=None, **kwargs): """Mutate the current color to a new color.""" - c = self._parse(color, data, alpha, filters=filters, variables=variables, **kwargs) - self._space = c + self._space, self._coords = self._parse( + color, data=data, alpha=alpha, filters=filters, variables=variables, **kwargs + ) return self diff --git a/custom/tmtheme.py b/custom/tmtheme.py index b14c330f..8426021e 100644 --- a/custom/tmtheme.py +++ b/custom/tmtheme.py @@ -1,8 +1,8 @@ """Custom color that looks for colors of format `#RRGGBBAA` as `#AARRGGBB`.""" -from ..lib.coloraide import Color from ..lib.coloraide.spaces.srgb.css import SRGB from ..lib.coloraide.css import parse, serialize import re +from ColorHelper.ch_util import get_base_color RE_COMPRESS = re.compile(r'(?i)^#({hex})\1({hex})\2({hex})\3(?:({hex})\4)?$'.format(**parse.COLOR_PARTS)) @@ -693,8 +693,9 @@ def name2hex(name): class SRGBX11(SRGB): """sRGB class.""" + @classmethod def to_string( - self, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs + cls, parent, *, alpha=None, precision=None, fit=True, none=False, **kwargs ): """Convert to CSS.""" @@ -738,7 +739,7 @@ def match(cls, string, start=0, fullmatch=True): return None -class ColorSRGBX11(Color): +class ColorSRGBX11(get_base_color()): """Hex SRGB with X11 color names.""" diff --git a/docs/src/markdown/settings/color_picker.md b/docs/src/markdown/settings/color_picker.md index 0c0911b0..86be8412 100644 --- a/docs/src/markdown/settings/color_picker.md +++ b/docs/src/markdown/settings/color_picker.md @@ -55,7 +55,7 @@ there are none enabled, `srgb` will be used as a last resort. ```js // If "auto" mode is disabled, or the "auto" mode could not determine a suitable picker, - // the preferrreed color picker space will be used. If the preferred is invalid, the + // the preferred color picker space will be used. If the preferred is invalid, the // first picker from `enabled_color_picker_modes` will be used, and if that is not valid, // `srgb` will be used. "preferred_color_picker_mode": "hsl", diff --git a/docs/src/markdown/settings/rules.md b/docs/src/markdown/settings/rules.md index 9a9bf3f5..9ebedff1 100644 --- a/docs/src/markdown/settings/rules.md +++ b/docs/src/markdown/settings/rules.md @@ -1,5 +1,49 @@ # Configuring ColorHelper +## `add_to_default_spaces` + +ColorHelper uses the `coloraide` library to provide support for all the different color spaces. `coloraide` ships with +a lot of color spaces, but only registers a select few in order to properly support CSS and a couple specific features. +ColorHelper provides a way to register additional color spaces that ship with `coloraide` (or even custom color spaces +written by a user) via the `add_to_default_spaces`. + +By default, ColorHelper does enable a few additional spaces, but a user can more if they have a need. It should be noted +that a restart of Sublime Text is required for these changes to take affect as changes to this setting affect the plugin +throughout. + +```js + // This option requires a restart of Sublime Text. + // This allows a user to add NEW previously unincluded color spaces. + // This ensures that the new color space will work in palettes etc. + // Then, custom color classes can override the space for special, file + // specific formatting via the `class` attribute under `user_color_classes`. + "add_to_default_spaces": [ + // "ColorHelper.lib.coloraide.spaces.cmy.CMY", + // "ColorHelper.lib.coloraide.spaces.cmyk.CMYK", + // "ColorHelper.lib.coloraide.spaces.din99o.Din99o", + // "ColorHelper.lib.coloraide.spaces.hsi.HSI", + // "ColorHelper.lib.coloraide.spaces.hunter_lab.HunterLab", + // "ColorHelper.lib.coloraide.spaces.ictcp.ICtCp", + // "ColorHelper.lib.coloraide.spaces.igtgpg.IgTgPg", + // "ColorHelper.lib.coloraide.spaces.itp.ITP", + // "ColorHelper.lib.coloraide.spaces.jzazbz.Jzazbz", + // "ColorHelper.lib.coloraide.spaces.jzczhz.JzCzhz", + // "ColorHelper.lib.coloraide.spaces.lch99o.lch99o", + // "ColorHelper.lib.coloraide.spaces.orgb.ORGB", + // "ColorHelper.lib.coloraide.spaces.prismatic.Prismatic", + // "ColorHelper.lib.coloraide.spaces.rec2100pq.Rec2100PQ", + // "ColorHelper.lib.coloraide.spaces.rlab.RLAB", + // "ColorHelper.lib.coloraide.spaces.ucs.UCS", + // "ColorHelper.lib.coloraide.spaces.uvw.UVW", + // "ColorHelper.lib.coloraide.spaces.xyy.XyY", + "ColorHelper.lib.coloraide.spaces.hsluv.HSLuv", + "ColorHelper.lib.coloraide.spaces.lchuv.Lchuv", + "ColorHelper.lib.coloraide.spaces.luv.Luv", + "ColorHelper.lib.coloraide.spaces.okhsl.Okhsl", + "ColorHelper.lib.coloraide.spaces.okhsv.Okhsv" + ], +``` + ## `color_rules` The `color_rules` option configures how ColorHelper interacts with a given file. In order for ColorHelper to inject @@ -187,18 +231,22 @@ accepts a string defining a single color class. It will not accept multiple colo ## `color_classes` ColorHelper uses the `Color` class from the [`coloraide`][coloraide] dependency to manage, manipulate, and translate -colors. By default, these color classes accept inputs that match valid CSS. They also output colors in the form of valid -CSS. +colors. By default, this color class contains color spaces that accept inputs that match valid CSS. They also output +colors in the form of valid CSS. -It may be desirable to filter out certain color spaces, or even alter a color space to accept different input formats -and generate different output formats. This can all be done by subclassing the `Color` class. +For some file types, it may be desirable to filter out certain color spaces, alter the default output options, or even +alter the input and output formats entirely. -`color_classes` allows you to configure the `Color` class, or point to a custom `Color` class and configure it. +`color_classes` is a dictionary of color profiles that link to a specific `Color` class and provides various options +you can tweak related to the `Color` class. -`color_classes` is a dictionary of color profiles that link to a specific `Color` class. You can tweak options -specifically related to the `Color` class. The **key** is the name of the color profile which can be referenced by +The **key** of the dictionary is the name of the color profile which can be referenced by [`color_rules`](#color_rules). The **value** is a dictionary of options. +Generally, either the base color space should be used (`ColorHelper.lib.coloraide.Color`) or one of the available +[custom color classes](https://github.com/facelessuser/ColorHelper/tree/master/custom). If none of these are sufficient, +it is possible to create your own custom class. + ### `output` This can be used to specify the output options available when converting a color or inserting a color from the color @@ -221,19 +269,41 @@ To learn more about available options, see [`coloraide`'s documentation][colorai ### `class` -This allows a user to specify a custom color class derived from `coloraide.Color`. This could be used to reference -a custom color class that can recognize different formats when scanning for colors. A custom color class will often also -provide different string outputs and string output options. +This allows a user to specify a custom color class derived from the default color class. -The value should be the full import path for the `Color` class. +The default color class is `ColorHelper.lib.coloraide.Color`. ColorHelper also provides some additional custom classes +which are found [here](https://github.com/facelessuser/ColorHelper/tree/master/custom). -ColorHelper provides a few custom color classes in `ColorHelper.custom`. You can check out those to see how to create -your own. +The value should be the full import path for the `Color` class. ```js "class": "ColorHelper.custom.tmtheme.ColorSRGBX11", ``` +If none of the provided color classes are sufficient, it is possible to create your own custom class. With that said, +there are a few things to note: + +- Custom classes should be derived from the default base class, but there is a catch, ColorHelper handles the default + (`ColorHelper.lib.coloraide.Color`) special. This allows us to enable the users with the ability of defining what + color spaces the default class contains via the [`add_to_default_spaces`](#add_to_default_spaces) setting. In turn, + ensures all color spaces properly function in features like palettes, etc. + + So, if creating a custom color space, the user should call `ColorHelper.ch_util.get_base_color()` to get the actual + default class to derive from. Users should **not** derive directly from `ColorHelper.lib.coloraide.Color`. + +- Colors are passed back and forth between custom color classes and the default color class. As long as both classes + know how to handle the color space, things should work without issue. + + If a custom color class is using a color space that is not registered under the default class or is using a color + space derived from an unregistered color space, some features won't work. + + In short, it is import to ensure that all color spaces that are actively used in custom color classes are + registered via [`add_to_default_spaces`](#add_to_default_spaces). + + If you are creating a brand new color space, you must also register it, or a version of that color space, with the + default color class. The registered color space must support the `color(id ...)` input and output format as that + format is often used when passing a color around internally within ColorAide. + ### `filters` A list that restricts color recognition to only the specified color spaces. Default is an empty list which allows all diff --git a/lib/coloraide/__init__.py b/lib/coloraide/__init__.py index 08ddcda9..782c3dd7 100644 --- a/lib/coloraide/__init__.py +++ b/lib/coloraide/__init__.py @@ -1,7 +1,7 @@ """ColorAide Library.""" from .__meta__ import __version_info__, __version__ # noqa: F401 -from .color import Color, ColorMatch -from .interpolate import Piecewise, Lerp +from .color import Color, ColorAll, ColorMatch +from .interpolate import stop, hint from .algebra import NaN -__all__ = ("Color", "ColorMatch", "NaN", "Piecewise", "Lerp") +__all__ = ("Color", "ColorAll", "ColorMatch", "NaN", "stop", "hint") diff --git a/lib/coloraide/__meta__.py b/lib/coloraide/__meta__.py index b434393f..dd213d5b 100644 --- a/lib/coloraide/__meta__.py +++ b/lib/coloraide/__meta__.py @@ -192,5 +192,5 @@ def parse_version(ver: str) -> Version: return Version(major, minor, micro, release, pre, post, dev) -__version_info__ = Version(0, 15, 1, "final") +__version_info__ = Version(1, 0, 0, "beta", 1) __version__ = __version_info__._get_canonical() diff --git a/lib/coloraide/algebra.py b/lib/coloraide/algebra.py index a6e3ec21..de136654 100644 --- a/lib/coloraide/algebra.py +++ b/lib/coloraide/algebra.py @@ -23,10 +23,10 @@ import sys import math import operator -from functools import reduce +import functools from itertools import zip_longest as zipl from .types import ArrayLike, MatrixLike, VectorLike, Array, Matrix, Vector, SupportsFloatOrInt -from typing import Optional, Callable, Sequence, List, Union, Iterator, Tuple, Any, Iterable, cast +from typing import Optional, Callable, Sequence, List, Union, Iterator, Tuple, Any, Iterable, overload, cast NaN = float('nan') INF = float('inf') @@ -38,7 +38,10 @@ def prod(values: Iterable[SupportsFloatOrInt]) -> SupportsFloatOrInt: """Get the product of a list of numbers.""" - return reduce((lambda x, y: x * y), values) + if not values: + return 1 + + return functools.reduce(lambda x, y: x * y, values) # Shortcut for math operations # Specify one of these in divide, multiply, dot, etc. @@ -152,17 +155,183 @@ def npow(base: float, exp: float) -> float: return math.copysign(abs(base) ** exp, base) +def lerp(a: float, b: float, t: float) -> float: + """Linear interpolation.""" + + return a + (b - a) * t + + ################################ # Matrix/linear algebra math ################################ -def _vector_dot(a: VectorLike, b: VectorLike) -> float: +def vdot(a: VectorLike, b: VectorLike) -> float: """Dot two vectors.""" return sum([x * y for x, y in zipl(a, b)]) +def vcross(v1: VectorLike, v2: VectorLike) -> Vector: # pragma: no cover + """ + Cross two vectors. + + Takes vectors of either 2 or 3 dimensions. If 2 dimensions, will return the z component. + To mix 2 and 3 vector components, please use `cross` instead which will pad 2 dimension + vectors if the other is of 3 dimensions. `cross` has more overhead, so use `cross` if + you don't need broadcasting of any kind. + """ + + if len(v1) == len(v2) == 2: + return [v1[0] * v2[1] - v1[1] * v2[0]] + else: + return [ + v1[1] * v2[2] - v1[2] * v2[1], + v1[2] * v2[0] - v2[2] * v1[0], + v1[0] * v2[1] - v1[1] * v2[0] + ] + + +@overload +def acopy(a: VectorLike) -> Vector: + ... + + +@overload +def acopy(a: MatrixLike) -> Matrix: + ... + + +def acopy(a: ArrayLike) -> Array: + """Array copy.""" + + return cast(Array, [(acopy(i) if isinstance(i, Sequence) else i) for i in a]) + + +@overload +def _cross_pad(a: VectorLike, s: Tuple[int, ...]) -> Vector: + ... + + +@overload +def _cross_pad(a: MatrixLike, s: Tuple[int, ...]) -> Matrix: + ... + + +def _cross_pad(a: ArrayLike, s: Tuple[int, ...]) -> Array: + """Pad an array with 2-D vectors.""" + + m = acopy(a) + + # Initialize indexes so we can properly write our data + total = prod(cast(Iterator[int], s[:-1])) + idx = [0] * (len(s) - 1) + + for c in range(total): + t = m # type: Any + for i in idx: + t = t[i] + + t.append(0) + + if c < (total - 1): + for x in range(len(s) - 1): + if (idx[x] + 1) % s[x] == 0: + idx[x] = 0 + x += 1 + else: + idx[x] += 1 + break + return m + + +@overload +def cross(a: VectorLike, b: VectorLike) -> Vector: + ... + + +@overload +def cross(a: MatrixLike, b: Union[VectorLike, MatrixLike]) -> Matrix: + ... + + +@overload +def cross(a: Union[VectorLike, MatrixLike], b: MatrixLike) -> Matrix: + ... + + +def cross(a: ArrayLike, b: ArrayLike) -> Array: + """Vector cross product.""" + + # Determine shape of arrays + shape_a = shape(a) + shape_b = shape(b) + dims_a = len(shape_a) + dims_b = len(shape_b) + + # Avoid crossing vectors of the wrong size or scalars + if not shape_a or not shape_b or not (1 < shape_a[-1] < 4) or not (1 < shape_b[-1] < 4): + raise ValueError('Values must contain vectors of dimensions 2 or 3') + + # Pad 2-D vectors + if shape_a[-1] != shape_b[-1]: + if shape_a[-1] == 2: + a = _cross_pad(a, shape_a) + shape_a = shape_a[:-1] + (3,) + else: + b = _cross_pad(b, shape_b) + shape_b = shape_b[:-1] + (3,) + + if dims_a == 1: + if dims_b == 1: + # Cross two vectors + return vcross(cast(VectorLike, a), cast(VectorLike, b)) + elif dims_b == 2: + # Cross a vector and a 2-D matrix + return [vcross(cast(VectorLike, a), cast(VectorLike, r)) for r in b] + else: + # Cross a vector and an N-D matrix + return cast( + Matrix, + reshape( + [vcross(cast(VectorLike, a), cast(VectorLike, r)) for r in _extract_dims(b, dims_b, dims_b - 1)], + shape_b + ) + ) + elif dims_a == 2: + if dims_b == 1: + # Cross a 2-D matrix and a vector + return [vcross(cast(VectorLike, r), cast(VectorLike, b)) for r in a] + elif dims_b == 1: + # Cross an N-D matrix and a vector + return cast( + Matrix, + reshape( + [vcross(cast(VectorLike, r), cast(VectorLike, b)) for r in _extract_dims(a, dims_a, dims_a - 1)], + shape_a + ) + ) + + # Cross an N-D and M-D matrix + bcast = broadcast(a, b) + a2 = [] + b2 = [] + data = [] + count = 1 + size = bcast.shape[-1] + for x, y in bcast: + a2.append(x) + b2.append(y) + if count == size: + data.append(vcross(a2, b2)) + a2 = [] + b2 = [] + count = 0 + count += 1 + return cast(Matrix, reshape(data, bcast.shape)) + + def _extract_dims( m: ArrayLike, + total: int, target: int, depth: int = 0 ) -> Iterator[ArrayLike]: @@ -174,13 +343,58 @@ def _extract_dims( """ if depth == target: - if isinstance(m[0], Sequence): - yield cast(ArrayLike, [[cast(ArrayLike, x)[r] for x in m] for r in range(len(m[0]))]) + if total != 1: + yield cast(ArrayLike, [[cast(ArrayLike, x)[r] for x in m] for r in range(len(cast(ArrayLike, m[0])))]) else: yield m else: for m2 in m: - yield from cast(ArrayLike, _extract_dims(cast(ArrayLike, m2), target, depth + 1)) + yield from cast(ArrayLike, _extract_dims(cast(ArrayLike, m2), total - 1, target, depth + 1)) + + +@overload +def dot(a: float, b: float, *, dims: Optional[Tuple[int, int]] = None) -> float: + ... + + +@overload +def dot(a: float, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def dot(a: VectorLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def dot(a: float, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def dot(a: MatrixLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def dot(a: VectorLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> float: + ... + + +@overload +def dot(a: VectorLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def dot(a: MatrixLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def dot(a: MatrixLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... def dot( @@ -211,20 +425,21 @@ def dot( if dims_a and dims_b and dims_a > 2 or dims_b > 2: if dims_a == 1: # Dot product of vector and a M-D matrix - cols1 = list(_extract_dims(cast(MatrixLike, b), dims_b - 2)) + cols1 = list(_extract_dims(cast(MatrixLike, b), dims_b, dims_b - 2)) shape_c = shape_b[:-2] + shape_b[-1:] return cast( Matrix, - reshape( - [[_vector_dot(cast(VectorLike, a), cast(VectorLike, c)) for c in col] for col in cols1], - shape_c - ) + reshape([[vdot(cast(VectorLike, a), cast(VectorLike, c)) for c in col] for col in cols1], shape_c) ) else: # Dot product of N-D and M-D matrices # Resultant size: `dot(xy, yz) = xz` or `dot(nxy, myz) = nxmz` - cols2 = list(_extract_dims(cast(ArrayLike, b), dims_b - 2)) if dims_b > 1 else cast(ArrayLike, [[b]]) - rows = list(_extract_dims(cast(ArrayLike, a), dims_a - 1)) + cols2 = ( + list(_extract_dims(cast(ArrayLike, b), dims_b, dims_b - 2)) + if dims_b > 1 + else cast(ArrayLike, [[b]]) + ) + rows = list(_extract_dims(cast(ArrayLike, a), dims_a, dims_a - 1)) m2 = [ [[sum(cast(List[float], multiply(row, c))) for c in cast(VectorLike, col)] for col in cols2] for row in rows @@ -241,21 +456,18 @@ def dot( if dims_a == 1: if dims_b == 1: # Dot product of two vectors - return _vector_dot(cast(VectorLike, a), cast(VectorLike, b)) + return vdot(cast(VectorLike, a), cast(VectorLike, b)) elif dims_b == 2: # Dot product of vector and a matrix - return cast(Vector, [_vector_dot(cast(VectorLike, a), col) for col in zipl(*cast(MatrixLike, b))]) + return cast(Vector, [vdot(cast(VectorLike, a), col) for col in zipl(*cast(MatrixLike, b))]) elif dims_a == 2: if dims_b == 1: # Dot product of matrix and a vector - return cast(Vector, [_vector_dot(row, cast(VectorLike, b)) for row in cast(MatrixLike, a)]) + return cast(Vector, [vdot(row, cast(VectorLike, b)) for row in cast(MatrixLike, a)]) elif dims_b == 2: # Dot product of two matrices - return cast( - Matrix, - [[_vector_dot(row, col) for col in zipl(*cast(MatrixLike, b))] for row in cast(MatrixLike, a)] - ) + return cast(Matrix, [[vdot(row, col) for col in zipl(*cast(MatrixLike, b))] for row in cast(MatrixLike, a)]) # Trying to dot a number with a vector or a matrix, so just multiply return multiply(a, b, dims=(dims_a, dims_b)) @@ -378,7 +590,7 @@ def multi_dot(arrays: Sequence[ArrayLike]) -> Union[float, Array]: elif is_vector: return ravel(value) else: - return value + return cast(Matrix, value) def _vector_math(op: Callable[..., float], a: VectorLike, b: VectorLike) -> Vector: @@ -393,6 +605,51 @@ def _vector_math(op: Callable[..., float], a: VectorLike, b: VectorLike) -> Vect return [op(x, y) for x, y in zipl(a, b)] +@overload +def _math(op: Callable[..., float], a: float, b: float, *, dims: Optional[Tuple[int, int]] = None) -> float: + ... + + +@overload +def _math(op: Callable[..., float], a: float, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def _math(op: Callable[..., float], a: VectorLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def _math(op: Callable[..., float], a: float, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def _math(op: Callable[..., float], a: MatrixLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def _math(op: Callable[..., float], a: VectorLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def _math(op: Callable[..., float], a: VectorLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def _math(op: Callable[..., float], a: MatrixLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def _math(op: Callable[..., float], a: MatrixLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + def _math( op: Callable[..., float], a: Union[float, ArrayLike], @@ -479,6 +736,51 @@ def _math( return cast(Matrix, [_vector_math(op, row, cast(VectorLike, b)) for row in cast(MatrixLike, a)]) +@overload +def divide(a: float, b: float, *, dims: Optional[Tuple[int, int]] = None) -> float: + ... + + +@overload +def divide(a: float, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def divide(a: VectorLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def divide(a: float, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def divide(a: MatrixLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def divide(a: VectorLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def divide(a: VectorLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def divide(a: MatrixLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def divide(a: MatrixLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + def divide( a: Union[float, ArrayLike], b: Union[float, ArrayLike], @@ -490,6 +792,51 @@ def divide( return _math(operator.truediv, a, b, dims=dims) +@overload +def multiply(a: float, b: float, *, dims: Optional[Tuple[int, int]] = None) -> float: + ... + + +@overload +def multiply(a: float, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def multiply(a: VectorLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def multiply(a: float, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def multiply(a: MatrixLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def multiply(a: VectorLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def multiply(a: VectorLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def multiply(a: MatrixLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def multiply(a: MatrixLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + def multiply( a: Union[float, ArrayLike], b: Union[float, ArrayLike], @@ -501,6 +848,51 @@ def multiply( return _math(operator.mul, a, b, dims=dims) +@overload +def add(a: float, b: float, *, dims: Optional[Tuple[int, int]] = None) -> float: + ... + + +@overload +def add(a: float, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def add(a: VectorLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def add(a: float, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def add(a: MatrixLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def add(a: VectorLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def add(a: VectorLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def add(a: MatrixLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def add(a: MatrixLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + def add( a: Union[float, ArrayLike], b: Union[float, ArrayLike], @@ -512,6 +904,51 @@ def add( return _math(operator.add, a, b, dims=dims) +@overload +def subtract(a: float, b: float, *, dims: Optional[Tuple[int, int]] = None) -> float: + ... + + +@overload +def subtract(a: float, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def subtract(a: VectorLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def subtract(a: float, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def subtract(a: MatrixLike, b: float, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def subtract(a: VectorLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Vector: + ... + + +@overload +def subtract(a: VectorLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def subtract(a: MatrixLike, b: VectorLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + +@overload +def subtract(a: MatrixLike, b: MatrixLike, *, dims: Optional[Tuple[int, int]] = None) -> Matrix: + ... + + def subtract( a: Union[float, ArrayLike], b: Union[float, ArrayLike], @@ -537,7 +974,7 @@ class BroadcastTo: - The new shape. """ - def __init__(self, array: ArrayLike, orig: Tuple[int, ...], old: Tuple[int, ...], new: Tuple[int, ...]) -> None: + def __init__(self, array: ArrayLike, old: Tuple[int, ...], new: Tuple[int, ...]) -> None: """Initialize.""" self._loop1 = 0 @@ -568,16 +1005,15 @@ def __init__(self, array: ArrayLike, orig: Tuple[int, ...], old: Tuple[int, ...] # Calculate how many times we should replicate data both horizontally and vertically # We need to flip them based on whether the original shape has an even or odd number of # dimensions. - delta_rank = len(new) - len(old) - counters = [int(x / y) if y else y for x, y in zip(new[delta_rank:], old)] - repeat = prod(counters[:-1]) if len(old) > 1 else 1 - expand = counters[-1] - if len(orig) % 2: - self.expand = repeat + diff = [int(x / y) if y else y for x, y in zip(new, old)] + repeat = prod(diff[:-1]) if len(old) > 1 else 1 + expand = diff[-1] + if len(diff) > 1 and diff[-2] > 1: self.repeat = expand + self.expand = repeat else: - self.expand = expand self.repeat = repeat + self.expand = expand else: # There is no modifications that need to be made on this array, # So we'll be chunking it without any cleverness. @@ -654,11 +1090,11 @@ def __init__(self, *arrays: ArrayLike) -> None: # Determine maximum dimensions shapes = [] - arrays2 = [] max_dims = 0 for a in arrays: - arrays2.append([a] if not isinstance(a, Sequence) else a) - s = shape(arrays2[-1]) + s = shape(a) + if not s: + s = (1,) dims = len(s) if dims > max_dims: max_dims = dims @@ -684,8 +1120,8 @@ def __init__(self, *arrays: ArrayLike) -> None: # Create iterators to "broadcast to" self.iters = [] - for a, s0, s1 in zip(arrays2, shapes, stage1_shapes): - self.iters.append(BroadcastTo(a, s0, s1, common)) + for a, s1 in zip(arrays, stage1_shapes): + self.iters.append(BroadcastTo(a, s1, common)) # I don't think this is done the same way as `numpy`. # But shouldn't matter for what we do. @@ -749,7 +1185,7 @@ def broadcast_to(a: ArrayLike, s: Union[int, Sequence[int]]) -> Array: if d1 != d2 and (d1 != 1 or d1 > d2): raise ValueError("Cannot broadcast {} to {}".format(s_orig, s)) - return cast(Array, reshape(list(BroadcastTo(a, s_orig, tuple(s1), tuple(s))), s)) + return cast(Array, reshape(list(BroadcastTo(a, tuple(s1), tuple(s))), s)) def full(array_shape: Union[int, Sequence[int]], fill_value: Union[float, ArrayLike]) -> Array: @@ -784,7 +1220,7 @@ def zeros(array_shape: Union[int, Sequence[int]]) -> Array: def identity(size: int) -> Matrix: """Create an identity matrix.""" - return cast(Matrix, diag([1.0] * size)) + return eye(size) def _flatiter(array: ArrayLike, array_shape: Tuple[int, ...]) -> Iterator[float]: @@ -794,19 +1230,20 @@ def _flatiter(array: ArrayLike, array_shape: Tuple[int, ...]) -> Iterator[float] for a in array: if nested: yield from _flatiter(cast(ArrayLike, a), array_shape[1:]) - elif isinstance(a, Sequence): - raise ValueError('Ragged arrays are not supported') else: - yield a + yield cast(float, a) -def flatiter(array: ArrayLike) -> Iterator[float]: +def flatiter(array: Union[float, ArrayLike]) -> Iterator[float]: """Traverse an array returning values.""" - yield from _flatiter(array, shape(array)) + if not isinstance(array, Sequence): + yield array + else: + yield from _flatiter(array, shape(array)) -def ravel(array: ArrayLike) -> Vector: +def ravel(array: Union[float, ArrayLike]) -> Vector: """Return a flattened vector.""" return list(flatiter(array)) @@ -845,6 +1282,16 @@ def arange( return list(_frange(float(start), float(stop), float(step))) +@overload +def transpose(array: VectorLike) -> Vector: + ... + + +@overload +def transpose(array: Matrix) -> Matrix: + ... + + def transpose(array: ArrayLike) -> Array: """ A simple transpose of a matrix. @@ -909,7 +1356,7 @@ def reshape(array: ArrayLike, new_shape: Union[int, Sequence[int]]) -> Union[flo # Shape to a scalar if not new_shape: v = ravel(array) - if len(v) == 1 and not isinstance(v[0], Sequence): + if len(v) == 1: return v[0] else: raise ValueError('Shape {} does not match the data total of {}'.format(new_shape, shape(array))) @@ -968,14 +1415,12 @@ def _shape(array: ArrayLike, size: int) -> Tuple[int, ...]: deeper = True for a in array: if not isinstance(a, Sequence) or size != len(a): - return tuple() + raise ValueError('Ragged lists are not supported') elif deeper: if a and isinstance(a[0], Sequence): if size2 < 0: size2 = len(a[0]) s2 = _shape(a, size2) - if not s2: - break else: deeper = False s2 = tuple() @@ -987,16 +1432,34 @@ def shape(array: Union[float, ArrayLike]) -> Tuple[int, ...]: if isinstance(array, Sequence): s = (len(array),) + + # Zero length vector if not s[0]: - return tuple() - elif not isinstance(array[0], Sequence): - return tuple(s) - return s + _shape(array, len(array[0])) + return s + + # Handle scalars + is_scalar = False + all_scalar = True + for a in array: + if not isinstance(a, Sequence): + is_scalar = True + if not all_scalar: + break + else: + all_scalar = False + if is_scalar: + if all_scalar: + return s + raise ValueError('Ragged lists are not supported') + + # Looks like we only have sequences + return s + _shape(array, len(cast(ArrayLike, array[0]))) else: + # Scalar return tuple() -def fill_diagonal(matrix: ArrayLike, val: Union[float, ArrayLike] = 0.0, wrap: bool = False) -> None: +def fill_diagonal(matrix: MatrixLike, val: Union[float, ArrayLike] = 0.0, wrap: bool = False) -> None: """Fill an N-D matrix diagonal.""" s = shape(matrix) @@ -1056,6 +1519,16 @@ def eye(n: int, m: Optional[int] = None, k: int = 0) -> Matrix: return a +@overload +def diag(array: VectorLike, k: int = 0) -> Matrix: + ... + + +@overload +def diag(array: Matrix, k: int = 0) -> Vector: + ... + + def diag(array: ArrayLike, k: int = 0) -> Array: """Create a diagonal matrix from a vector or return a vector of the diagonal of a matrix.""" @@ -1092,7 +1565,7 @@ def diag(array: ArrayLike, k: int = 0) -> Array: return d -def inv(matrix: ArrayLike) -> Matrix: +def inv(matrix: MatrixLike) -> Matrix: """ Invert the matrix. @@ -1137,16 +1610,16 @@ def inv(matrix: ArrayLike) -> Matrix: # Handle dimensions greater than 2 x 2 elif dims > 2: invert = [] - cols = list(_extract_dims(matrix, dims - 2)) + cols = list(_extract_dims(matrix, dims, dims - 2)) for c in cols: invert.append(transpose(inv(cast(Matrix, c)))) return cast(Matrix, reshape(cast(Matrix, invert), s)) indices = list(range(s[0])) - m = [list(x) for x in cast(Matrix, matrix)] + m = acopy(matrix) # Create an identity matrix of the same size as our provided vector - im = cast(List[List[float]], diag([1] * s[0])) + im = diag([1] * s[0]) # 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. @@ -1209,7 +1682,7 @@ def vstack(arrays: Tuple[ArrayLike, ...]) -> Array: return sum(cast(Iterable[Array], m), cast(Array, [])) -def _hstack_extract(a: Array, s: Tuple[int, ...]) -> Iterator[Vector]: +def _hstack_extract(a: ArrayLike, s: Sequence[int]) -> Iterator[Vector]: """Extract data from the second dimension.""" data = flatiter(a) @@ -1247,7 +1720,7 @@ def hstack(arrays: Tuple[ArrayLike, ...]) -> Array: raise ValueError("'hstack' requires at least one array") # Iterate the arrays returning the content per second dimension - m = [] # type: List[Array] + m = [] # type: List[Any] for data in zipl(*[_hstack_extract(a, s) for a, s in zipl(arrays, shapes)]): m.extend(sum(data, [])) @@ -1259,8 +1732,8 @@ def hstack(arrays: Tuple[ArrayLike, ...]) -> Array: def outer(a: Union[float, ArrayLike], b: Union[float, ArrayLike]) -> Matrix: """Compute the outer product of two vectors (or flattened matrices).""" - v1 = ravel(a) if isinstance(a, Sequence) else [a] - v2 = ravel(b) if isinstance(b, Sequence) else [b] + v1 = ravel(a) + v2 = ravel(b) return [[x * y for y in v2] for x in v1] @@ -1286,14 +1759,14 @@ def inner(a: Union[float, ArrayLike], b: Union[float, ArrayLike]) -> Union[float if dims_a == 1: first = [a] # type: Any elif dims_a > 2: - first = list(_extract_dims(cast(ArrayLike, a), dims_a - 1)) + first = list(_extract_dims(cast(ArrayLike, a), dims_a, dims_a - 1)) else: first = a if dims_b == 1: second = [b] # type: Any elif dims_b > 2: - second = list(_extract_dims(cast(ArrayLike, b), dims_b - 1)) + second = list(_extract_dims(cast(ArrayLike, b), dims_b, dims_b - 1)) else: second = b diff --git a/lib/coloraide/cat.py b/lib/coloraide/cat.py index 48a9a67a..15cb06c7 100644 --- a/lib/coloraide/cat.py +++ b/lib/coloraide/cat.py @@ -1,9 +1,10 @@ """Chromatic adaptation transforms.""" from . import util +from abc import ABCMeta, abstractmethod from . import algebra as alg -from functools import lru_cache -from .types import MatrixLike, Matrix, VectorLike, Vector -from typing import Tuple, Dict, cast +import functools +from .types import Matrix, VectorLike, Vector, Plugin +from typing import Any, Type, Dict, Tuple, cast # From CIE 2004 Colorimetry T.3 and T.8 # B from https://en.wikipedia.org/wiki/Standard_illuminant#White_point @@ -37,68 +38,11 @@ } } -# 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] - ], - "cat16": [ - # https://arxiv.org/pdf/1802.06067.pdf - [-0.401288, -0.250268, -0.002079], - [0.650173, 1.204414, 0.048952], - [-0.051461, -0.045854, -0.953127] - ] -} # type: Dict[str, MatrixLike] - -@lru_cache(maxsize=20) def calc_adaptation_matrices( w1: Tuple[float, float], w2: Tuple[float, float], - method: str = 'bradford' + m: Matrix, ) -> Tuple[Matrix, Matrix]: """ Get the von Kries based adaptation matrix based on the method and illuminants. @@ -112,56 +56,184 @@ def calc_adaptation_matrices( 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 = alg.inv(m) + first = alg.dot(m, util.xy_to_xyz(w1), dims=alg.D2_D1) + second = alg.dot(m, util.xy_to_xyz(w2), dims=alg.D2_D1) + m2 = alg.diag(alg.divide(first, second, dims=alg.D1)) + adapt = cast(Matrix, alg.multi_dot([alg.inv(m), m2, m])) - try: - first = alg.dot(m, util.xy_to_xyz(w1), dims=alg.D2_D1) - except KeyError: # pragma: no cover - raise ValueError('Unknown white point encountered: {}'.format(w1)) + return adapt, alg.inv(adapt) - try: - second = alg.dot(m, util.xy_to_xyz(w2), dims=alg.D2_D1) - except KeyError: # pragma: no cover - raise ValueError('Unknown white point encountered: {}'.format(w2)) - m2 = cast( - Matrix, - alg.diag(cast(Vector, alg.divide(cast(Vector, first), cast(Vector, second), dims=alg.D1))) - ) - adapt = cast(Matrix, alg.multi_dot([mi, m2, m])) +class CATMeta(ABCMeta): + """Meta class for CAT plugin.""" - return adapt, alg.inv(adapt) + def __init__(cls, name: str, bases: Tuple[object, ...], clsdict: Dict[str, Any]) -> None: + """Cache best filter.""" + + @classmethod # type: ignore[misc] + @functools.lru_cache(maxsize=6) + def get_adaptation_matrices( + cls: Type['CAT'], + w1: Tuple[float, float], + w2: Tuple[float, float] + ) -> Tuple[Matrix, Matrix]: + """Get the adaptation matrices.""" + + return calc_adaptation_matrices(w1, w2, cls.MATRIX) + + cls.get_adaptation_matrices = get_adaptation_matrices -def get_adaptation_matrix(w1: Tuple[float, float], w2: Tuple[float, float], method: str) -> Matrix: +class CAT(Plugin, metaclass=CATMeta): + """Chromatic adaptation.""" + + MATRIX = [[]] # type: Matrix + + @classmethod + @abstractmethod + def adapt(cls, w1: Tuple[float, float], w2: Tuple[float, float], xyz: VectorLike) -> Vector: + """Adapt a given XYZ color using the provided white points.""" + + +class VonKries(CAT): """ - Get the appropriate matrix for chromatic adaptation. + Von Kries CAT. - 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. + http://brucelindbloom.com/Eqn_ChromAdapt.html + https://hrcak.srce.hr/file/95370 """ - a, b = sorted([w1, w2]) - m, mi = calc_adaptation_matrices(a, b, method) - return mi if a != w2 else m + NAME = 'von-kries' + MATRIX = [ + [0.4002400, 0.7076000, -0.0808100], + [-0.2263000, 1.1653200, 0.0457000], + [0.0000000, 0.0000000, 0.9182200] + ] -def chromatic_adaptation( - w1: Tuple[float, float], - w2: Tuple[float, float], - xyz: VectorLike, - method: str = 'bradford' -) -> Vector: - """Chromatic adaptation.""" + @classmethod + def adapt(cls, w1: Tuple[float, float], w2: Tuple[float, float], xyz: VectorLike) -> Vector: + """Adapt a given XYZ color using the provided white points.""" + + # We are already using the correct white point + if w1 == w2: + return list(xyz) + + a, b = sorted([w1, w2]) + m, mi = cls.get_adaptation_matrices(a, b) + return alg.dot(mi if a != w2 else m, xyz, dims=alg.D2_D1) + + +class Bradford(VonKries): + """ + Bradford CAT. + + http://brucelindbloom.com/Eqn_ChromAdapt.html + https://hrcak.srce.hr/file/95370 + """ + + NAME = "bradford" + + MATRIX = [ + [0.8951000, 0.2664000, -0.1614000], + [-0.7502000, 1.7135000, 0.0367000], + [0.0389000, -0.0685000, 1.0296000] + ] + + +class XYZScaling(VonKries): + """ + XYZ Scaling CAT. + + http://brucelindbloom.com/Eqn_ChromAdapt.html + https://hrcak.srce.hr/file/95370 + """ - if w1 == w2: - # No adaptation is needed if the white points are identical. - return list(xyz) - else: - # Get the appropriate chromatic adaptation matrix and apply. - return cast(Vector, alg.dot(get_adaptation_matrix(w1, w2, method), xyz, dims=alg.D2_D1)) + NAME = "xyz-scaling" + + MATRIX = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1] + ] + + +class CAT02(VonKries): + """ + CAT02 CAT. + + https://en.wikipedia.org/wiki/CIECAM02#CAT02 + """ + + NAME = "cat02" + + MATRIX = [ + [0.7328000, 0.4296000, -0.1624000], + [-0.7036000, 1.6975000, 0.0061000], + [0.0030000, 0.0136000, 0.9834000] + ] + + +class CMCCAT97(VonKries): + """ + CMCCAT97 CAT. + + https://hrcak.srce.hr/file/95370 + """ + + NAME = "cmccat97" + + MATRIX = [ + [0.8951000, -0.7502000, 0.0389000], + [0.2664000, 1.7135000, 0.0685000], + [-0.1614000, 0.0367000, 1.0296000], + ] + + +class Sharp(VonKries): + """ + Sharp CAT. + + https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.14.918&rep=rep1&type=pdf + """ + + NAME = "sharp" + + MATRIX = [ + [1.2694000, -0.0988000, -0.1706000], + [-0.8364000, 1.8006000, 0.0357000], + [0.0297000, -0.0315000, 1.0018000] + ] + + +class CMCCAT2000(VonKries): + """ + CMCCAT2000 CAT. + + https://hrcak.srce.hr/file/95370 + https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.14.918&rep=rep1&type=pdf + """ + + NAME = 'cmccat2000' + + MATRIX = [ + [0.7982000, 0.3389000, -0.1371000], + [-0.5918000, 1.5512000, 0.0406000], + [0.0008000, 0.0239000, 0.9753000] + ] + + +class CAT16(VonKries): + """ + CAT16 CAT. + + https://arxiv.org/pdf/1802.06067.pdf + """ + + NAME = "cat16" + + MATRIX = [ + [-0.401288, -0.250268, -0.002079], + [0.650173, 1.204414, 0.048952], + [-0.051461, -0.045854, -0.953127] + ] diff --git a/lib/coloraide/channels.py b/lib/coloraide/channels.py new file mode 100644 index 00000000..744eef98 --- /dev/null +++ b/lib/coloraide/channels.py @@ -0,0 +1,43 @@ +"""Channels.""" +from typing import Tuple, Optional + +FLG_ANGLE = 1 +FLG_PERCENT = 2 +FLG_OPT_PERCENT = 4 +FLG_MIRROR_PERCENT = 8 + + +class Channel(str): + """Channel.""" + + # low: float + # high: float + # span: float + # offset: float + # bound: bool + # flags: int + # limit: Tuple[Optional[float], Optional[float]] + + def __new__( + cls, + name: str, + low: float, + high: float, + mirror_range: bool = False, + bound: bool = False, + flags: int = 0, + limit: Tuple[Optional[float], Optional[float]] = (None, None) + ) -> 'Channel': + """Initialize.""" + + obj = super().__new__(cls, name) + obj.low = low + obj.high = high + mirror = flags & FLG_MIRROR_PERCENT and abs(low) == high + obj.span = high if mirror else high - low + obj.offset = 0.0 if mirror else -low + obj.bound = bound + obj.flags = flags + obj.limit = limit + + return obj diff --git a/lib/coloraide/color.py b/lib/coloraide/color.py index a304870f..f1bf224d 100644 --- a/lib/coloraide/color.py +++ b/lib/coloraide/color.py @@ -1,14 +1,16 @@ """Colors.""" import abc import functools -from . import cat from . import distance from . import convert from . import gamut from . import compositing from . import interpolate +from . import filters +from . import harmonies from . import util from . import algebra as alg +from itertools import zip_longest as zipl from .css import parse from .types import VectorLike, Vector, ColorInput from .spaces import Space, Cylindrical @@ -22,9 +24,14 @@ from .spaces.lab_d65 import LabD65 from .spaces.lch_d65 import LchD65 from .spaces.display_p3 import DisplayP3 +from .spaces.display_p3_linear import DisplayP3Linear from .spaces.a98_rgb import A98RGB +from .spaces.a98_rgb_linear import A98RGBLinear from .spaces.prophoto_rgb import ProPhotoRGB +from .spaces.prophoto_rgb_linear import ProPhotoRGBLinear from .spaces.rec2020 import Rec2020 +from .spaces.rec2020_linear import Rec2020Linear +from .spaces.rec2100pq import Rec2100PQ from .spaces.xyz_d65 import XYZD65 from .spaces.xyz_d50 import XYZD50 from .spaces.oklab.css import Oklab @@ -39,6 +46,16 @@ from .spaces.hsluv import HSLuv from .spaces.okhsl import Okhsl from .spaces.okhsv import Okhsv +from .spaces.hsi import HSI +from .spaces.ipt import IPT +from .spaces.igpgtg import IgPgTg +from .spaces.cmy import CMY +from .spaces.cmyk import CMYK +from .spaces.xyy import XyY +from .spaces.hunter_lab import HunterLab +from .spaces.prismatic import Prismatic +from .spaces.rlab import RLAB +from .spaces.orgb import ORGB from .distance import DeltaE from .distance.delta_e_76 import DE76 from .distance.delta_e_94 import DE94 @@ -52,22 +69,40 @@ from .gamut import Fit from .gamut.fit_lch_chroma import LchChroma from .gamut.fit_oklch_chroma import OklchChroma -from .gamut.fit_css_color_4 import CssColor4 -from typing import Union, Sequence, Dict, List, Optional, Any, cast, Callable, Set, Tuple, Type, Mapping +from .cat import CAT, Bradford, VonKries, XYZScaling, CAT02, CMCCAT97, Sharp, CMCCAT2000, CAT16 +from .filters import Filter +from .filters.w3c_filter_effects import Sepia, Brightness, Contrast, Saturate, Opacity, HueRotate, Grayscale, Invert +from .filters.cvd import Protan, Deutan, Tritan +from .types import Plugin +from typing import overload, Union, Sequence, Dict, List, Optional, Any, cast, Callable, Set, Tuple, Type, Mapping SUPPORTED_DE = ( - DE76, DE94, DECMC, DE2000, DEITP, DE99o, DEZ, DEHyAB, DEOK + DE76, DE94, DECMC, DE2000, DEHyAB, DEOK +) + +EXTRA_DE = ( + DEITP, DE99o, DEZ ) SUPPORTED_SPACES = ( - HSL, HWB, Lab, Lch, LabD65, LchD65, SRGB, SRGBLinear, HSV, - DisplayP3, A98RGB, ProPhotoRGB, Rec2020, XYZD65, XYZD50, - Oklab, Oklch, Jzazbz, JzCzhz, ICtCp, Din99o, Lch99o, Luv, Lchuv, - Okhsl, Okhsv, HSLuv + XYZD65, XYZD50, SRGB, SRGBLinear, DisplayP3, DisplayP3Linear, + Oklab, Oklch, Lab, Lch, LabD65, LchD65, HSV, HSL, HWB, Rec2020, Rec2020Linear, + A98RGB, A98RGBLinear, ProPhotoRGB, ProPhotoRGBLinear +) + +EXTRA_SPACES = ( + Rec2100PQ, Jzazbz, JzCzhz, ICtCp, Din99o, Lch99o, Luv, Lchuv, Okhsl, Okhsv, HSLuv, + HSI, IPT, IgPgTg, CMY, CMYK, XyY, HunterLab, Prismatic, RLAB, ORGB ) SUPPORTED_FIT = ( - LchChroma, OklchChroma, CssColor4 + LchChroma, OklchChroma +) + +SUPPORTED_CAT = (Bradford, VonKries, XYZScaling, CAT02, CMCCAT97, Sharp, CMCCAT2000, CAT16) + +SUPPORTED_FILTERS = ( + Sepia, Brightness, Contrast, Saturate, Opacity, HueRotate, Grayscale, Invert, Protan, Deutan, Tritan ) @@ -89,28 +124,49 @@ def __str__(self) -> str: # pragma: no cover __repr__ = __str__ -class BaseColor(abc.ABCMeta): +class ColorMeta(abc.ABCMeta): """Ensure on subclass that the subclass has new instances of mappings.""" def __init__(cls, name: str, bases: Tuple[object, ...], clsdict: Dict[str, Any]) -> None: """Copy mappings on subclass.""" + # Ensure subclassed Color objects do not use the same plugin mappings if len(cls.mro()) > 2: cls.CS_MAP = cls.CS_MAP.copy() # type: Dict[str, Type[Space]] cls.DE_MAP = cls.DE_MAP.copy() # type: Dict[str, Type[DeltaE]] cls.FIT_MAP = cls.FIT_MAP.copy() # type: Dict[str, Type[Fit]] + cls.CAT_MAP = cls.CAT_MAP.copy() # type: Dict[str, Type[CAT]] + cls.FILTER_MAP = cls.FILTER_MAP.copy() # type: Dict[str, Type[Filter]] + # Ensure each derived class tracks its own conversion paths for color spaces + # relative to the installed color space plugins. + @classmethod # type: ignore[misc] + @functools.lru_cache(maxsize=256) + def _get_convert_chain( + cls: Type['Color'], + space: Type['Space'], + target: str + ) -> List[Tuple[Type['Space'], Type['Space'], int, bool]]: + """Resolve a conversion chain, cache it for speed.""" -class Color(metaclass=BaseColor): + return convert.get_convert_chain(cls, space, target) + + cls._get_convert_chain = _get_convert_chain + + +class Color(metaclass=ColorMeta): """Color class object which provides access and manipulation of color spaces.""" CS_MAP = {} # type: Dict[str, Type[Space]] DE_MAP = {} # type: Dict[str, Type[DeltaE]] FIT_MAP = {} # type: Dict[str, Type[Fit]] + CAT_MAP = {} # type: Dict[str, Type[CAT]] + FILTER_MAP = {} # type: Dict[str, Type[Filter]] PRECISION = util.DEF_PREC FIT = util.DEF_FIT INTERPOLATE = util.DEF_INTERPOLATE DELTA_E = util.DEF_DELTA_E + HARMONY = util.DEF_HARMONY CHROMATIC_ADAPTATION = 'bradford' # It is highly unlikely that a user would ever need to override this, but @@ -134,17 +190,44 @@ def __init__( ) -> None: """Initialize.""" - self._space = self._parse(color, data, alpha, filters=filters, **kwargs) + self._space, self._coords = self._parse(color, data, alpha, filters=filters, **kwargs) + + def __len__(self) -> int: + """Get number of channels.""" + + return len(self._space.CHANNELS) + 1 + + @overload + def __getitem__(self, i: Union[str, int]) -> float: # noqa: D105 + ... - def __dir__(self) -> Sequence[str]: - """Get attributes for `dir()`.""" + @overload + def __getitem__(self, i: slice) -> Vector: # noqa: D105 + ... - attr = cast(List['str'], super().__dir__()) - attr.extend(self._space.CHANNEL_NAMES) - attr.extend('alpha') - attr.extend(list(self._space.CHANNEL_ALIASES.keys())) - attr.extend(['delta_e_{}'.format(name) for name in self.DE_MAP.keys()]) - return attr + def __getitem__(self, i: Union[str, int, slice]) -> Union[float, Vector]: + """Get channels.""" + + return self._coords[self._space.get_channel_index(i)] if isinstance(i, str) else self._coords[i] + + @overload + def __setitem__(self, i: Union[str, int], v: float) -> None: # noqa: D105 + ... + + @overload + def __setitem__(self, i: slice, v: Vector) -> None: # noqa: D105 + ... + + def __setitem__(self, i: Union[str, int, slice], v: Union[float, Vector]) -> None: + """Set channels.""" + + space = self._space + if isinstance(i, slice): + for index, value in zip(range(len(self._coords))[i], cast(Vector, v)): + self._coords[index] = alg.clamp(float(value), *space.get_channel(index).limit) + else: + index = space.get_channel_index(i) if isinstance(i, str) else i + self._coords[index] = alg.clamp(float(cast(float, v)), *space.get_channel(index).limit) def __eq__(self, other: Any) -> bool: """Compare equal.""" @@ -152,7 +235,7 @@ def __eq__(self, other: Any) -> bool: return ( type(other) == type(self) and other.space() == self.space() and - util.cmp_coords(other.coords() + [other.alpha], self.coords() + [self.alpha]) + util.cmp_coords(other[:], self[:]) ) @classmethod @@ -164,38 +247,42 @@ def _parse( *, filters: Optional[Sequence[str]] = None, **kwargs: Any - ) -> Space: + ) -> Tuple[Type[Space], List[float]]: """Parse the color.""" obj = None if isinstance(color, str): + # Parse a color space name and coordinates if data is not None: - s = color.lower() + s = color space_class = cls.CS_MAP.get(s) if space_class and (not filters or s in filters): - num_channels = len(space_class.CHANNEL_NAMES) + num_channels = len(space_class.CHANNELS) if len(data) < num_channels: data = list(data) + [alg.NaN] * (num_channels - len(data)) - obj = space_class(data[:num_channels], alpha) + coords = [alg.clamp(float(v), *c.limit) for c, v in zipl(space_class.CHANNELS, data)] + coords.append(alg.clamp(float(alpha), *space_class.get_channel(-1).limit)) + obj = space_class, coords # Parse a CSS string else: m = cls._match(color, fullmatch=True, filters=filters) if m is None: raise ValueError("'{}' is not a valid color".format(color)) - obj = m[0] + coords = [alg.clamp(float(v), *c.limit) for c, v in zipl(m[0].CHANNELS, m[1])] + coords.append(alg.clamp(float(m[2]), *m[0].get_channel(-1).limit)) + obj = m[0], coords elif isinstance(color, Color): # Handle a color instance if not filters or color.space() in filters: - obj = cls.CS_MAP[color.space()](color._space) + space_class = cls.CS_MAP[color.space()] + obj = space_class, color[:] elif isinstance(color, Mapping): # Handle a color dictionary space = color['space'] - if not filters or space in filters: - cs = cls.CS_MAP[space] - coords = [color[name] for name in cs.CHANNEL_NAMES] - alpha = color.get('alpha', 1) - obj = cs(coords, alpha) + coords = color['coords'] + alpha = color.get('alpha', 1.0) + obj = cls._parse(space, coords, alpha) else: raise TypeError("'{}' is an unrecognized type".format(type(color))) @@ -210,7 +297,7 @@ def _match( start: int = 0, fullmatch: bool = False, filters: Optional[Sequence[str]] = None - ) -> Optional[Tuple['Space', int, int]]: + ) -> Optional[Tuple[Type['Space'], Vector, float, int, int]]: """ Match a color in a buffer and return a color object. @@ -223,7 +310,7 @@ def _match( m = parse.parse_color(string, cls.CS_MAP, start, fullmatch) if m is not None: if not filter_set or m[0].NAME in filter_set: - return m[0](*m[1]), start, m[2] + return m[0], m[1][0], m[1][1], start, m[2] return None # Attempt color space specific match @@ -232,8 +319,7 @@ def _match( continue m2 = space_class.match(string, start, fullmatch) if m2 is not None: - color = space_class(*m2[0]) - return color, start, m2[1] + return space_class, m2[0][0], m2[0][1], start, m2[1] return None @classmethod @@ -249,8 +335,7 @@ def match( m = cls._match(string, start, fullmatch, filters=filters) if m is not None: - color = m[0] - return ColorMatch(cls(color.NAME, color.coords(), color.alpha), m[1], m[2]) + return ColorMatch(cls(m[0].NAME, m[1], m[2]), m[3], m[4]) return None @classmethod @@ -268,60 +353,93 @@ def _is_color(cls, obj: Any) -> bool: @classmethod def register( cls, - plugin: Union[Type[Fit], Type[DeltaE], Type[Space], Sequence[Any]], - overwrite: bool = False + plugin: Union[Type[Plugin], Sequence[Type[Plugin]]], + overwrite: bool = False, + silent: bool = False ) -> None: """Register the hook.""" + reset_convert_cache = False + if not isinstance(plugin, Sequence): plugin = [plugin] - mapping = None # type: Optional[Union[Dict[str, Type[Fit]], Dict[str, Type[DeltaE]], Dict[str, Type[Space]]]] + mapping = None # type: Optional[Dict[str, Type[Any]]] for p in plugin: if issubclass(p, Space): mapping = cls.CS_MAP + reset_convert_cache = True elif issubclass(p, DeltaE): mapping = cls.DE_MAP + elif issubclass(p, CAT): + mapping = cls.CAT_MAP + elif issubclass(p, Filter): + mapping = cls.FILTER_MAP elif issubclass(p, Fit): mapping = cls.FIT_MAP if p.NAME == 'clip': - raise ValueError("'{}' is a reserved name for gamut mapping/reduction and cannot be overridden") + if reset_convert_cache: # pragma: no cover + cls._get_convert_chain.cache_clear() + if not silent: + raise ValueError("'{}' is a reserved name for gamut mapping/reduction and cannot be overridden") + continue # pragma: no cover else: + if reset_convert_cache: # pragma: no cover + cls._get_convert_chain.cache_clear() raise TypeError("Cannot register plugin of type '{}'".format(type(p))) name = p.NAME value = p if name != "*" and name not in mapping or overwrite: - mapping[name] = value - else: + cast(Dict[str, Type[Plugin]], mapping)[name] = value + elif not silent: + if reset_convert_cache: # pragma: no cover + cls._get_convert_chain.cache_clear() raise ValueError("A plugin with the name of '{}' already exists or is not allowed".format(name)) + if reset_convert_cache: + cls._get_convert_chain.cache_clear() + @classmethod def deregister(cls, plugin: Union[str, Sequence[str]], silent: bool = False) -> None: """Deregister a plugin by name of specified plugin type.""" + reset_convert_cache = False + if isinstance(plugin, str): plugin = [plugin] - mapping = None # type: Optional[Union[Dict[str, Type[Fit]], Dict[str, Type[DeltaE]], Dict[str, Type[Space]]]] + mapping = None # type: Optional[Dict[str, Type[Any]]] for p in plugin: if p == '*': cls.CS_MAP.clear() cls.DE_MAP.clear() cls.FIT_MAP.clear() + cls.CAT_MAP.clear() return ptype, name = p.split(':', 1) if ptype == 'space': mapping = cls.CS_MAP + reset_convert_cache = True elif ptype == "delta-e": mapping = cls.DE_MAP + elif ptype == 'cat': + mapping = cls.CAT_MAP + elif ptype == 'filter': + mapping = cls.FILTER_MAP elif ptype == "fit": mapping = cls.FIT_MAP if name == 'clip': - raise ValueError("'{}' is a reserved name gamut mapping/reduction and cannot be removed") + if reset_convert_cache: # pragma: no cover + cls._get_convert_chain.cache_clear() + if not silent: + raise ValueError("'{}' is a reserved name gamut mapping/reduction and cannot be removed") + continue # pragma: no cover else: + if reset_convert_cache: # pragma: no cover + cls._get_convert_chain.cache_clear() raise ValueError("The plugin category of '{}' is not recognized".format(ptype)) if name == '*': @@ -329,23 +447,23 @@ def deregister(cls, plugin: Union[str, Sequence[str]], silent: bool = False) -> elif name in mapping: del mapping[name] elif not silent: + if reset_convert_cache: + cls._get_convert_chain.cache_clear() raise ValueError("A plugin of name '{}' under category '{}' could not be found".format(name, ptype)) + if reset_convert_cache: + cls._get_convert_chain.cache_clear() + def to_dict(self) -> Mapping[str, Any]: """Return color as a data object.""" - data = {'space': self.space()} # type: Dict[str, Any] - coords = self.coords() - for i, name in enumerate(self._space.CHANNEL_NAMES, 0): - data[name] = coords[i] - data['alpha'] = self.alpha - return data + return {'space': self.space(), 'coords': self[:-1], 'alpha': self[-1]} def normalize(self) -> 'Color': """Normalize the color.""" - coords, alpha = self._space.null_adjust(self.coords(), self.alpha) - return self.mutate(self.space(), coords, alpha) + self[:] = self._space.normalize(self[:]) + return self def is_nan(self, name: str) -> bool: """Check if channel is NaN.""" @@ -367,11 +485,6 @@ def space(self) -> str: return self._space.NAME - def coords(self) -> Vector: - """Coordinates.""" - - return self._space.coords() - def new( self, color: ColorInput, @@ -388,22 +501,27 @@ def new( def clone(self) -> 'Color': """Clone.""" - return self.new(self.space(), self.coords(), self.alpha) + return self.new(self.space(), self[:-1], self[-1]) def convert(self, space: str, *, fit: Union[bool, str] = False, in_place: bool = False) -> 'Color': """Convert to color space.""" - space = space.lower() - if fit: method = None if not isinstance(fit, str) else fit if not self.in_gamut(space, tolerance=0.0): converted = self.convert(space, in_place=in_place) - return converted.fit(space, method=method, in_place=True) + return converted.fit(space, method=method) - coords = convert.convert(self, space) + if space == self.space(): + return self if in_place else self.clone() - return self.mutate(space, coords, self.alpha) if in_place else self.new(space, coords, self.alpha) + c, coords = convert.convert(self, space) + coords.append(self[-1]) + this = self if in_place else self.clone() + this._space = c + this._coords = coords + + return this def mutate( self, @@ -416,8 +534,7 @@ def mutate( ) -> 'Color': """Mutate the current color to a new color.""" - c = self._parse(color, data=data, alpha=alpha, filters=filters, **kwargs) - self._space = c + self._space, self._coords = self._parse(color, data=data, alpha=alpha, filters=filters, **kwargs) return self def update( @@ -431,10 +548,9 @@ def update( ) -> 'Color': """Update the existing color space with the provided color.""" - c = self._parse(color, data=data, alpha=alpha, filters=filters, **kwargs) space = self.space() - self._space = c - if c.NAME != space: + self._space, self._coords = self._parse(color, data=data, alpha=alpha, filters=filters, **kwargs) + if self._space.NAME != space: self.convert(space, in_place=True) return self @@ -446,7 +562,11 @@ def to_string(self, **kwargs: Any) -> str: def __repr__(self) -> str: """Representation.""" - return repr(self._space) + return 'color({} {} / {})'.format( + self._space._serialize()[0], + ' '.join([util.fmt_float(coord, util.DEF_PREC) for coord in self[:-1]]), + util.fmt_float(self[-1], util.DEF_PREC) + ) __str__ = __repr__ @@ -470,47 +590,65 @@ def xy(self) -> Vector: """Convert to `xy`.""" xyz = self.convert('xyz-d65') - coords = cat.chromatic_adaptation( + coords = self.chromatic_adaptation( xyz._space.WHITE, self._space.WHITE, - xyz.coords(), - self.CHROMATIC_ADAPTATION + xyz[:-1] ) return util.xyz_to_xyY(coords, self._space.white())[:2] - def clip(self, space: Optional[str] = None, *, in_place: bool = False) -> 'Color': + @classmethod + def chromatic_adaptation( + cls, + w1: Tuple[float, float], + w2: Tuple[float, float], + xyz: VectorLike, + *, + method: Optional[str] = None + ) -> Vector: + """Chromatic adaptation.""" + + try: + adapter = cls.CAT_MAP[method if method is not None else cls.CHROMATIC_ADAPTATION] + except KeyError: + raise ValueError("'{}' is not a supported CAT".format(method)) + + return adapter.adapt(w1, w2, xyz) + + def clip(self, space: Optional[str] = None) -> 'Color': """Clip the color channels.""" + orig_space = self.space() if space is None: space = self.space() # Convert to desired space - c = self.convert(space) + c = self.convert(space, in_place=True) # If we are perfectly in gamut, don't waste time clipping. if c.in_gamut(tolerance=0.0): - if isinstance(c._space, Cylindrical): + if issubclass(c._space, Cylindrical): name = c._space.hue_name() - c.set(name, util.constrain_hue(c.get(name))) + c.set(name, util.constrain_hue(c[name])) else: gamut.clip_channels(c) # Adjust "this" color - return self.update(c) if in_place else c.convert(self.space(), in_place=True) + return c.convert(orig_space, in_place=True) def fit( self, space: Optional[str] = None, *, method: Optional[str] = None, - in_place: bool = False, **kwargs: Any ) -> 'Color': """Fit the gamut using the provided method.""" # Dedicated clip method. + orig_space = self.space() if method == 'clip' or (method is None and self.FIT == "clip"): - return self.clip(space, in_place=in_place) + return self.clip(space) if space is None: space = self.space() @@ -526,25 +664,26 @@ def fit( raise ValueError("'{}' gamut mapping is not currently supported".format(method)) # Convert to desired space - c = self.convert(space) + c = self.convert(space, in_place=True) # If we are perfectly in gamut, don't waste time fitting, just normalize hues. # If out of gamut, apply mapping/clipping/etc. if c.in_gamut(tolerance=0.0): - if isinstance(c._space, Cylindrical): + if issubclass(c._space, Cylindrical): name = c._space.hue_name() - c.set(name, util.constrain_hue(c.get(name))) + c.set(name, util.constrain_hue(c[name])) else: # Doesn't seem to be an easy way that `mypy` can know whether this is the ABC class or not func(c, **kwargs) # Adjust "this" color - return self.update(c) if in_place else c.convert(self.space(), in_place=True) + return c.convert(orig_space, in_place=True) def in_gamut(self, space: Optional[str] = None, *, tolerance: float = util.DEF_FIT_TOLERANCE) -> bool: """Check if current color is in gamut.""" - space = space.lower() if space is not None else self.space() + if space is None: + space = self.space() # Check gamut in the provided space if space is not None and space != self.space(): @@ -569,35 +708,11 @@ def mask(self, channel: Union[str, Sequence[str]], *, invert: bool = False, in_p masks = set( [aliases.get(channel, channel)] if isinstance(channel, str) else [aliases.get(c, c) for c in channel] ) - for name in (self._space.CHANNEL_NAMES + ('alpha',)): + for name in self._space.get_all_channels(): if (not invert and name in masks) or (invert and name not in masks): - this.set(name, alg.NaN) + this[name] = alg.NaN return this - def steps( - self, - color: Union[Union[ColorInput, interpolate.Piecewise], Sequence[Union[ColorInput, interpolate.Piecewise]]], - *, - steps: int = 2, - max_steps: int = 1000, - max_delta_e: float = 0, - delta_e: Optional[str] = None, - **interpolate_args: Any - ) -> List['Color']: - """ - Discrete steps. - - This is built upon the interpolate function, and will return a list of - colors containing a minimum of colors equal to `steps` or steps as specified - derived from the `max_delta_e` parameter (whichever is greatest). - - Number of colors can be capped with `max_steps`. - - Default delta E method used is delta E 76. - """ - - return self.interpolate(color, **interpolate_args).steps(steps, max_steps, max_delta_e, delta_e) - def mix( self, color: ColorInput, @@ -613,22 +728,38 @@ def mix( The basic mixing logic is outlined in the CSS level 5 draft. """ - if not self._is_color(color) and not isinstance(color, (str, interpolate.Piecewise, Mapping)): + if not self._is_color(color) and not isinstance(color, (str, Mapping)): raise TypeError("Unexpected type '{}'".format(type(color))) - mixed = self.interpolate(color, **interpolate_args)(percent) + mixed = self.interpolate([self, color], **interpolate_args)(percent) return self.mutate(mixed) if in_place else mixed + @classmethod + def steps( + cls, + colors: Sequence[Union[ColorInput, interpolate.common.stop, Callable[..., float]]], + *, + steps: int = 2, + max_steps: int = 1000, + max_delta_e: float = 0, + delta_e: Optional[str] = None, + **interpolate_args: Any + ) -> List['Color']: + """Discrete steps.""" + + return cls.interpolate(colors, **interpolate_args).steps(steps, max_steps, max_delta_e, delta_e) + + @classmethod def interpolate( - self, - color: Union[Union[ColorInput, interpolate.Piecewise], Sequence[Union[ColorInput, interpolate.Piecewise]]], + cls, + colors: Sequence[Union[ColorInput, interpolate.common.stop, Callable[..., float]]], *, space: Optional[str] = None, out_space: Optional[str] = None, - stop: float = 0, - progress: Optional[Callable[..., float]] = None, + progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]] = None, hue: str = util.DEF_HUE_ADJ, - premultiplied: bool = False - ) -> interpolate.Interpolator: + premultiplied: bool = True, + method: str = "linear" + ) -> interpolate.common.Interpolator: """ Return an interpolation function. @@ -641,40 +772,38 @@ def interpolate( mixing occurs. """ - space = (space if space is not None else self.INTERPOLATE).lower() - out_space = self.space() if out_space is None else out_space.lower() - - # A piecewise object was provided, so treat it as such, - # or we've changed the stop of the base color, so run it through piecewise. - if ( - isinstance(color, interpolate.Piecewise) or - (stop != 0 and (isinstance(color, (str, Mapping)) or self._is_color(color))) - ): - color = cast(Sequence[Union['Color', str, Mapping[str, Any], interpolate.Piecewise]], [color]) - - if not isinstance(color, str) and isinstance(color, Sequence): - # We have a sequence, so use piecewise interpolation - colors = list(color) - colors.insert(0, interpolate.Piecewise(self, stop=stop)) - return interpolate.color_piecewise_lerp( - colors, - space, - out_space, - progress, - hue, - premultiplied - ) - else: - # We have a sequence, so use piecewise interpolation - return interpolate.color_lerp( - self, - color, - space, - out_space, - progress, - hue, - premultiplied - ) + return interpolate.get_interpolator(method)( + cls, + colors=colors, + space=space, + out_space=out_space, + progress=progress, + hue=hue, + premultiplied=premultiplied + ) + + def filter( # noqa: A003 + self, + name: str, + amount: Optional[float] = None, + *, + space: Optional[str] = None, + in_place: bool = False, + **kwargs: Any + ) -> 'Color': + """Filter.""" + + return filters.filters(self, name, amount, space, in_place, **kwargs) + + def harmony( + self, + name: str, + *, + space: Optional[str] = None + ) -> List['Color']: + """Acquire the specified color harmonies.""" + + return harmonies.harmonize(self, name, space) def compose( self, @@ -695,8 +824,10 @@ def compose( color = compositing.compose(self, bcolor, blend, operator, space) - outspace = self.space() if out_space is None else out_space.lower() - color.convert(outspace, in_place=True) + if out_space is None: + out_space = self.space() + + color.convert(out_space, in_place=True) return self.mutate(color) if in_place else color def delta_e( @@ -712,12 +843,10 @@ def delta_e( if method is None: method = self.DELTA_E - algorithm = method.lower() - try: - return self.DE_MAP[algorithm].distance(self, color, **kwargs) + return self.DE_MAP[method].distance(self, color, **kwargs) except KeyError: - raise ValueError("'{}' is not currently a supported distancing algorithm.".format(algorithm)) + raise ValueError("'{}' is not currently a supported distancing algorithm.".format(method)) def distance(self, color: ColorInput, *, space: str = "lab") -> float: """Delta.""" @@ -738,7 +867,7 @@ def closest( def luminance(self) -> float: """Get color's luminance.""" - return cast(float, self.convert("xyz-d65").y) + return self.convert("xyz-d65")['y'] def contrast(self, color: ColorInput) -> float: """Compare the contrast ratio of this color and the provided color.""" @@ -755,61 +884,34 @@ def get(self, name: str) -> float: if '.' in name: space, channel = name.split('.', 1) obj = self.convert(space) - return obj._space.get(channel) + return obj[channel] - return self._space.get(name) + return self[name] - def set(self, name: str, value: Union[float, Callable[..., float]]) -> 'Color': # noqa: A003 + def set( # noqa: A003 + self, + name: str, + value: Union[float, Callable[..., float]] + ) -> 'Color': """Set channel.""" # Handle space.attribute if '.' in name: space, channel = name.split('.', 1) obj = self.convert(space) - obj._space.set(channel, value(self._space.get(channel)) if callable(value) else value) + obj[channel] = value(obj[channel]) if callable(value) else value return self.update(obj) # Handle a function that modifies the value or a direct value - self._space.set(name, value(self._space.get(name)) if callable(value) else value) - + self[name] = value(self[name]) if callable(value) else value return self - def __getattr__(self, name: str) -> Any: - """Get attribute.""" - - sc = super() - - # Skip private/protected names - if not name.startswith('_'): - # Try color space properties - try: - return sc.__getattribute__('_space').get(name) - except AttributeError: - pass - # Try delta E methods - if name.startswith('delta_e_'): - de = name[8:] - if de in sc.__getattribute__('DE_MAP'): - return functools.partial(super().__getattribute__('delta_e'), method=de) +Color.register(SUPPORTED_SPACES + SUPPORTED_DE + SUPPORTED_FIT + SUPPORTED_CAT + SUPPORTED_FILTERS) - # Do the Color class methods - sc.__getattribute__(name) - def __setattr__(self, name: str, value: Any) -> None: - """Set attribute.""" - - sc = super() - - if not name.startswith('_'): - try: - # See if we need to set the space specific channel attributes. - sc.__getattribute__('_space').set(name, value) - return - except AttributeError: # pragma: no cover - pass - # Set all attributes on the Color class. - sc.__setattr__(name, value) +class ColorAll(Color): + """Color derivative with all extra spaces.""" -Color.register(SUPPORTED_SPACES + SUPPORTED_DE + SUPPORTED_FIT) +ColorAll.register(EXTRA_DE + EXTRA_SPACES) diff --git a/lib/coloraide/compositing/__init__.py b/lib/coloraide/compositing/__init__.py index 6d69e880..81f9173c 100644 --- a/lib/coloraide/compositing/__init__.py +++ b/lib/coloraide/compositing/__init__.py @@ -6,23 +6,21 @@ from . import porter_duff from . import blend_modes from .. import algebra as alg -from ..types import Vector -from ..gamut.bounds import GamutBound, Bounds -from typing import Optional, Union, Callable, List, TYPE_CHECKING +from ..channels import Channel +from typing import Optional, Union, List, Type, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color -def clip_channel(coord: float, bounds: Bounds) -> float: +def clip_channel(coord: float, channel: Channel) -> float: """Clipping channel.""" - a = bounds.lower # type: Optional[float] - b = bounds.upper # type: Optional[float] - is_bound = isinstance(bounds, GamutBound) + a = channel.low # type: Optional[float] + b = channel.high # type: Optional[float] # These parameters are unbounded - if not is_bound: # pragma: no cover + if not channel.bound: # pragma: no cover # Will not execute unless we have a space that defines some coordinates # as bound and others as not. We do not currently have such spaces. a = None @@ -35,17 +33,16 @@ def clip_channel(coord: float, bounds: Bounds) -> float: def apply_compositing( color1: 'Color', color2: 'Color', - blend: Union[str, bool], - operator: Union[str, bool], - non_seperable: bool + blender: Optional[Type[blend_modes.Blend]], + operator: Union[str, bool] ) -> 'Color': """Perform the actual blending.""" # Get the color coordinates - csa = alg.no_nan(color1.alpha) - cba = alg.no_nan(color2.alpha) - coords1 = alg.no_nans(color1.coords()) - coords2 = alg.no_nans(color2.coords()) + csa = alg.no_nan(color1[-1]) + cba = alg.no_nan(color2[-1]) + coords1 = alg.no_nans(color1[:-1]) + coords2 = alg.no_nans(color2[:-1]) # Setup compositing compositor = None # type: Optional[porter_duff.PorterDuff] @@ -58,39 +55,18 @@ def apply_compositing( cra = compositor.ao() # Perform compositing - bounds = color1._space.BOUNDS - coords = [] # type: Vector - if isinstance(blend, str) and non_seperable: - # Setup blend mode. - ns_blender = blend_modes.get_non_seperable_blender(blend.lower()) - - # Convert to a hue, saturation, luminosity space and apply the requested blending. - # Afterwards, clip and apply alpha compositing. - i = 0 - blended = ns_blender(coords2, coords1) if ns_blender is not None else coords1 - for cb, cr in zip(coords2, blended): - cr = (1 - cba) * cr + cba * cr if ns_blender is not None else cr - cr = clip_channel(cr, bounds[i]) - coords.append(compositor.co(cb, cr) if compositor is not None else cr) - i += 1 - else: - # Setup blend mode. - blender = None # type: Optional[Callable[[float, float], float]] - if isinstance(blend, str): - blend = blend.lower() - blender = blend_modes.get_seperable_blender(blend) - elif blend is True: - blender = blend_modes.get_seperable_blender('normal') + channels = color1._space.CHANNELS - # Blend each channel. Afterward, clip and apply alpha compositing. - i = 0 - for cb, cs in zip(coords2, coords1): - cr = (1 - cba) * cs + cba * blender(cb, cs) if blender is not None else cs - cr = clip_channel(cr, bounds[i]) - coords.append(compositor.co(cb, cr) if compositor is not None else cr) - i += 1 + # Blend each channel. Afterward, clip and apply alpha compositing. + i = 0 + for cb, cr in zip(coords2, blender.blend(coords2, coords1) if blender else coords1): + cr = (1 - cba) * cr + cba * cr if blender else cr + cr = clip_channel(cr, channels[i]) + color1[i] = compositor.co(cb, cr) if compositor else cr + i += 1 - return color1.update(color1.space(), coords, cra) + color1[-1] = cra + return color1 def compose( @@ -102,10 +78,18 @@ def compose( ) -> 'Color': """Blend colors using the specified blend mode.""" + # We need to go ahead and grab the blender as we need to check what type of blender it is. + blender = None # Optional[blend_modes.Blend] + if isinstance(blend, str): + blender = blend_modes.get_blender(blend) + elif blend is True: + blender = blend_modes.get_blender('normal') + is_seperable = blender is not None and issubclass(blender, blend_modes.NonSeperableBlend) + # If we are doing non-separable, we are converting to a special space that # can only be done from sRGB, so we have to force sRGB anyway. - non_seperable = blend_modes.is_non_seperable(blend) - space = 'srgb' if space is None or non_seperable else space.lower() + if space is None or is_seperable: + space = 'srgb' if not backdrop: return color @@ -114,10 +98,10 @@ def compose( dest = backdrop[-1].convert(space) for x in range(len(backdrop) - 2, -1, -1): src = backdrop[x].convert(space) - dest = apply_compositing(src, dest, blend, operator, non_seperable) + dest = apply_compositing(src, dest, blender, operator) else: dest = backdrop[0].convert(space) src = color.convert(space) - return apply_compositing(src, dest, blend, operator, non_seperable) + return apply_compositing(src, dest, blender, operator) diff --git a/lib/coloraide/compositing/blend_modes.py b/lib/coloraide/compositing/blend_modes.py index fb78aad5..cbe55510 100644 --- a/lib/coloraide/compositing/blend_modes.py +++ b/lib/coloraide/compositing/blend_modes.py @@ -1,16 +1,11 @@ """Blend modes.""" import math +from abc import ABCMeta, abstractmethod from operator import itemgetter -from typing import Any, Callable, cast +from typing import Dict, Type from ..types import Vector -def is_non_seperable(mode: Any) -> bool: - """Check if blend mode is non-separable.""" - - return mode in frozenset(['color', 'hue', 'saturation', 'luminosity']) - - # ----------------------------------------- # Non-separable blending helper functions # ----------------------------------------- @@ -68,144 +63,257 @@ def set_sat(rgb: Vector, s: float) -> Vector: # ----------------------------------------- # Blend modes # ----------------------------------------- -def blend_normal(cb: float, cs: float) -> float: - """Blend mode 'normal'.""" +class Blend(metaclass=ABCMeta): + """Blend base class.""" - return cs + @classmethod + @abstractmethod + def blend(cls, coords1: Vector, coords2: Vector) -> Vector: # pragma: no cover + """Blend coordinates.""" + raise NotImplementedError('blend is not implemented') -def blend_multiply(cb: float, cs: float) -> float: - """Blend mode 'multiply'.""" - return cb * cs +class SeperableBlend(Blend): + """Blend coordinates.""" + @classmethod + @abstractmethod + def apply(cls, cb: float, cs: float) -> float: # pragma: no cover + """Blend two values.""" -def blend_screen(cb: float, cs: float) -> float: - """Blend mode 'screen'.""" + raise NotImplementedError('apply is not implemented') - return cb + cs - (cb * cs) + @classmethod + def blend(cls, coords1: Vector, coords2: Vector) -> Vector: + """Apply blending logic.""" + return [cls.apply(cb, cs) for cb, cs in zip(coords1, coords2)] -def blend_darken(cb: float, cs: float) -> float: - """Blend mode 'darken'.""" - return min(cb, cs) +class NonSeperableBlend(Blend): + """Non seperable blend method.""" + @classmethod + @abstractmethod + def apply(cls, cb: Vector, cs: Vector) -> Vector: # pragma: no cover + """Blend two vectors.""" -def blend_lighten(cb: float, cs: float) -> float: - """Blend mode 'lighten'.""" + raise NotImplementedError('apply is not implemented') - return max(cb, cs) + @classmethod + def blend(cls, coords_backgrond: Vector, coords_source: Vector) -> Vector: + """Apply blending logic.""" + return cls.apply(coords_backgrond, coords_source) -def blend_color_dodge(cb: float, cs: float) -> float: - """Blend mode 'dodge'.""" - if cb == 0: - return 0 - elif cs == 1: - return 1 - else: - return min(1, cb / (1 - cs)) +class BlendNormal(SeperableBlend): + """Normal blend mode.""" + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" -def blend_color_burn(cb: float, cs: float) -> float: - """Blend mode 'burn'.""" + return cs - if cb == 1: - return 1 - elif cs == 0: - return 0 - else: - return 1 - min(1, (1 - cb) / cs) +class BlendMultiply(SeperableBlend): + """Multiply blend mode.""" -def blend_overlay(cb: float, cs: float) -> float: - """Blend mode 'overlay'.""" + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" - if cb >= 0.5: - return blend_screen(cb, 2 * cs - 1) - else: - return blend_multiply(cb, cs * 2) + return cb * cs -def blend_difference(cb: float, cs: float) -> float: - """Blend mode 'difference'.""" +class BlendScreen(SeperableBlend): + """Screen blend mode.""" - return abs(cb - cs) + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" + return cb + cs - (cb * cs) -def blend_exclusion(cb: float, cs: float) -> float: - """Blend mode 'exclusion'.""" - return cb + cs - 2 * cb * cs +class BlendDarken(SeperableBlend): + """Darken blend mode.""" + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" -def blend_hard_light(cb: float, cs: float) -> float: - """Blend mode 'hard-light'.""" + return min(cb, cs) - if cs <= 0.5: - return blend_multiply(cb, cs * 2) - else: - return blend_screen(cb, 2 * cs - 1) +class BlendLighten(SeperableBlend): + """Lighten blend mode.""" -def blend_soft_light(cb: float, cs: float) -> float: - """Blend mode 'soft-light'.""" + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" - if cs <= 0.5: - return cb - (1 - 2 * cs) * cb * (1 - cb) - else: - if cb <= 0.25: - d = ((16 * cb - 12) * cb + 4) * cb + return max(cb, cs) + + +class BlendColorDodge(SeperableBlend): + """Color dodge blend mode.""" + + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" + + if cb == 0: + return 0 + elif cs == 1: + return 1 else: - d = math.sqrt(cb) - return cb + (2 * cs - 1) * (d - cb) + return min(1, cb / (1 - cs)) -def non_seperable_blend_hue(cb: Vector, cs: Vector) -> Vector: - """Blend mode 'hue'.""" +class BlendColorBurn(SeperableBlend): + """Color Burn blend mode.""" - return set_lum(set_sat(cs, sat(cb)), lum(cb)) + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" + if cb == 1: + return 1 + elif cs == 0: + return 0 + else: + return 1 - min(1, (1 - cb) / cs) -def non_seperable_blend_saturation(cb: Vector, cs: Vector) -> Vector: - """Blend mode 'saturation'.""" - return set_lum(set_sat(cb, sat(cs)), lum(cb)) +class BlendOverlay(SeperableBlend): + """Overlay blend mode.""" + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" -def non_seperable_blend_luminosity(cb: Vector, cs: Vector) -> Vector: - """Blend mode 'luminosity'.""" - return set_lum(cb, lum(cs)) + if cb >= 0.5: + return BlendScreen.apply(cb, 2 * cs - 1) + else: + return BlendMultiply.apply(cb, cs * 2) -def non_seperable_blend_color(cb: Vector, cs: Vector) -> Vector: - """Blend mode 'color'.""" +class BlendDifference(SeperableBlend): + """Difference blend mode.""" - return set_lum(cs, lum(cb)) + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" + return abs(cb - cs) -def get_seperable_blender(blend: str) -> Callable[[float, float], float]: - """Get desired blend mode.""" - try: - return cast( - Callable[[float, float], float], - globals()['blend_{}'.format(blend.replace('-', '_'))] - ) - except KeyError: - raise ValueError("'{}' is not a recognized blend mode".format(blend)) +class BlendExclusion(SeperableBlend): + """Exclusion blend mode.""" + + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" + + return cb + cs - 2 * cb * cs + + +class BlendHardLight(SeperableBlend): + """Hard light blend mode.""" + + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" + + if cs <= 0.5: + return BlendMultiply.apply(cb, cs * 2) + else: + return BlendScreen.apply(cb, 2 * cs - 1) -def get_non_seperable_blender(blend: str) -> Callable[[Vector, Vector], Vector]: +class BlendSoftLight(SeperableBlend): + """Soft light blend mode.""" + + @classmethod + def apply(cls, cb: float, cs: float) -> float: + """Blend two values.""" + + if cs <= 0.5: + return cb - (1 - 2 * cs) * cb * (1 - cb) + else: + if cb <= 0.25: + d = ((16 * cb - 12) * cb + 4) * cb + else: + d = math.sqrt(cb) + return cb + (2 * cs - 1) * (d - cb) + + +class BlendHue(NonSeperableBlend): + """Hue blend mode.""" + + @classmethod + def apply(cls, cb: Vector, cs: Vector) -> Vector: + """Blend two vectors.""" + + return set_lum(set_sat(cs, sat(cb)), lum(cb)) + + +class BlendSaturation(NonSeperableBlend): + """Saturation blend mode.""" + + @classmethod + def apply(cls, cb: Vector, cs: Vector) -> Vector: + """Blend two vectors.""" + + return set_lum(set_sat(cb, sat(cs)), lum(cb)) + + +class BlendLuminosity(NonSeperableBlend): + """Luminosity blend mode.""" + + @classmethod + def apply(cls, cb: Vector, cs: Vector) -> Vector: + """Blend two vectors.""" + return set_lum(cb, lum(cs)) + + +class BlendColor(NonSeperableBlend): + """Color blend mode.""" + + @classmethod + def apply(cls, cb: Vector, cs: Vector) -> Vector: + """Blend two vectors.""" + + return set_lum(cs, lum(cb)) + + +SUPPORTED = { + "normal": BlendNormal, + "multiply": BlendMultiply, + "screen": BlendScreen, + "darken": BlendDarken, + "lighten": BlendLighten, + "color-dodge": BlendColorDodge, + "color-burn": BlendColorBurn, + "overlay": BlendOverlay, + "difference": BlendDifference, + "exclusion": BlendExclusion, + "hard-light": BlendHardLight, + "soft-light": BlendSoftLight, + "hue": BlendHue, + "saturation": BlendSaturation, + "luminosity": BlendLuminosity, + "color": BlendColor, +} # type: Dict[str, Type[Blend]] + + +def get_blender(blend: str) -> Type[Blend]: """Get desired blend mode.""" try: - return cast( - Callable[[Vector, Vector], Vector], - globals()['non_seperable_blend_{}'.format(blend.replace('-', '_'))] - ) - except KeyError: # pragma: no cover - # The way we use this function, we will never hit this as we've verified the method before calling - raise ValueError("'{}' is not a recognized non seperable blend mode".format(blend)) + return SUPPORTED[blend] + except KeyError: + raise ValueError("'{}' is not a recognized blend mode".format(blend)) diff --git a/lib/coloraide/compositing/porter_duff.py b/lib/coloraide/compositing/porter_duff.py index d39cd376..e3ede61b 100644 --- a/lib/coloraide/compositing/porter_duff.py +++ b/lib/coloraide/compositing/porter_duff.py @@ -13,13 +13,13 @@ def __init__(self, cba: float, csa: float) -> None: self.csa = csa @abstractmethod - def _fa(self) -> float: # pragma: no cover + def fa(self) -> float: # pragma: no cover """Calculate `Fa`.""" raise NotImplementedError('fa is not implemented') @abstractmethod - def _fb(self) -> float: # pragma: no cover + def fb(self) -> float: # pragma: no cover """Calculate `Fb`.""" raise NotImplementedError('fb is not implemented') @@ -27,23 +27,23 @@ def _fb(self) -> float: # pragma: no cover def co(self, cb: float, cs: float) -> float: """Calculate premultiplied coordinate.""" - return self.csa * self._fa() * cs + self.cba * self._fb() * cb + return self.csa * self.fa() * cs + self.cba * self.fb() * cb def ao(self) -> float: """Calculate output alpha.""" - return self.csa * self._fa() + self.cba * self._fb() + return self.csa * self.fa() + self.cba * self.fb() class Clear(PorterDuff): """Clear.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 0 - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 0 @@ -52,12 +52,12 @@ def _fb(self) -> float: class Copy(PorterDuff): """Copy.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 1 - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 0 @@ -66,12 +66,12 @@ def _fb(self) -> float: class Destination(PorterDuff): """Destination.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 0 - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 1 @@ -80,12 +80,12 @@ def _fb(self) -> float: class SourceOver(PorterDuff): """Source over.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 1 - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 1 - self.csa @@ -94,12 +94,12 @@ def _fb(self) -> float: class DestinationOver(PorterDuff): """Destination over.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 1 - self.cba - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 1 @@ -108,12 +108,12 @@ def _fb(self) -> float: class SourceIn(PorterDuff): """Source in.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return self.cba - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 0 @@ -122,12 +122,12 @@ def _fb(self) -> float: class DestinationeIn(PorterDuff): """Destination in.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 0 - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return self.csa @@ -136,12 +136,12 @@ def _fb(self) -> float: class SourceOut(PorterDuff): """Source out.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 1 - self.cba - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 0 @@ -150,12 +150,12 @@ def _fb(self) -> float: class DestinationOut(PorterDuff): """Destination out.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 0 - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 1 - self.csa @@ -164,12 +164,12 @@ def _fb(self) -> float: class SourceAtop(PorterDuff): """Source atop.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return self.cba - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 1 - self.csa @@ -178,12 +178,12 @@ def _fb(self) -> float: class DestinationAtop(PorterDuff): """Destination atop.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 1 - self.cba - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return self.csa @@ -192,12 +192,12 @@ def _fb(self) -> float: class XOR(PorterDuff): """XOR.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 1 - self.cba - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 1 - self.csa @@ -206,12 +206,12 @@ def _fb(self) -> float: class Lighter(PorterDuff): """Lighter.""" - def _fa(self) -> float: + def fa(self) -> float: """Calculate `Fa`.""" return 1 - def _fb(self) -> float: + def fb(self) -> float: """Calculate `Fb`.""" return 1 @@ -237,7 +237,7 @@ def _fb(self) -> float: def compositor(name: str) -> Type[PorterDuff]: """Get the requested compositor.""" - name = name.lower() - if name not in SUPPORTED: + try: + return SUPPORTED[name] + except KeyError: raise ValueError("'{}' compositing is not supported".format(name)) - return SUPPORTED[name] diff --git a/lib/coloraide/convert.py b/lib/coloraide/convert.py index 6d7c93d8..3a13e4de 100644 --- a/lib/coloraide/convert.py +++ b/lib/coloraide/convert.py @@ -1,97 +1,152 @@ """Convert the color.""" from . import algebra as alg -from . import cat from .types import Vector -from typing import TYPE_CHECKING +from typing import Type, Tuple, Dict, List, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from .color import Color + from .spaces import Space # XYZ is the absolute base, meaning that XYZ is the final base in any conversion chain. # This is a design expectation regardless of whether someone assigns a different base to XYZ or not. ABSOLUTE_BASE = 'xyz-d65' -def convert(color: 'Color', space: str) -> Vector: - """Convert the color coordinates to the specified space.""" +def calc_path_to_xyz( + color: Type['Color'], + space: str +) -> Tuple[List[Type['Space']], Dict[str, int]]: + """ + Calculate the conversion path between a given color space and XYZ D65. + + We create two structures: + + 1. A list containing the color space name in the conversion process from our target to XYZ D65. + 2. A mapping of color space names to the index in the color space name list. + """ + + obj = color.CS_MAP.get(space) + if obj is None: + raise ValueError("'{}' is not a valid color space".format(space)) + + # Create a worse case conversion chain from XYZ to the target + temp = obj + count = 0 + from_color = [] + from_color_index = {} + name = '' + while name != ABSOLUTE_BASE: + from_color.append(temp) + name = temp.NAME + from_color_index[name] = count + temp = color.CS_MAP[temp.BASE] + + count += 1 + if count > color._MAX_CONVERT_ITERATIONS: # pragma: no cover + raise RuntimeError( + 'Conversion chain reached max size of {} and has terminated to avoid an infinite loop'.format( + count + ) + ) + + return from_color, from_color_index + + +def get_convert_chain( + color: Type['Color'], + space: Type['Space'], + target: str +) -> List[Tuple[Type['Space'], Type['Space'], int, bool]]: + """ + Create a conversion chain. + + Each entry in the list will contain (from_space, to_space, direction, chromatic_adaptation_needed). + Direction refers to whether conversions are moving to or from XYZ D65 as that will dictate whether + `to_base` or `from_base` call method is used. If either the "from" or "to" color space is XYZ D65 + a chromatic adaptation will need to occur. + """ + + # Get the color space chain for the current space to XYZ + from_color, from_color_index = calc_path_to_xyz(color, target) + + # Start building up the conversion chain. + # The first stage builds up the chain towards XYZ D65. + # If the color space we are converting to is not between + # the current space and XYZ D65, nothing will get added. + current = space + chain = [] # type: List[Tuple[Type['Space'], Type['Space'], int, bool]] + if current.NAME != ABSOLUTE_BASE: + count = 0 + while current.NAME not in from_color_index: - if color.space() != space: - obj = color.CS_MAP.get(space) - if obj is None: - raise ValueError("'{}' is not a valid color space".format(space)) + # Get the "base space" (the space through which the current color converts to and from) + base_space = color.CS_MAP[current.BASE] - # Create a worse case conversion chain from XYZ to the target - temp = obj - count = 0 - from_color = [] - from_color_index = {} - name = '' - while name != ABSOLUTE_BASE: - from_color.append(temp) - name = temp.NAME - from_color_index[name] = count - temp = color.CS_MAP[temp.BASE] + # Do we need to chromatically adapt towards XYZ D65? + adapt = base_space.NAME == ABSOLUTE_BASE + + # Add conversion chain entry + chain.append((current, base_space, 0, adapt)) + + # The base space is now the current space + current = base_space count += 1 if count > color._MAX_CONVERT_ITERATIONS: # pragma: no cover raise RuntimeError( - 'Conversion chain reached max size of {} and has terminated to avoid an infinite loop'.format( + 'Conversions reached max iteration of {} and has terminated to avoid an infinite loop'.format( count ) ) - # Treat undefined channels as zero - coords = alg.no_nans(color.coords()) - - # Start converting coordinates until we either match a space in the conversion chain or bottom out at XYZ D65 - current = type(color._space) - if current.NAME != ABSOLUTE_BASE: - count = 0 - while current.NAME not in from_color_index: - # Convert to color's base - base_space = color.CS_MAP[current.BASE] - coords = current.to_base(coords) - - # Convert to XYZ, make sure we chromatically adapt to the appropriate white point - if base_space.NAME == ABSOLUTE_BASE: - coords = cat.chromatic_adaptation( - current.WHITE, - base_space.WHITE, - coords, - color.CHROMATIC_ADAPTATION - ) + # If the chain still didn't resolve to the target space after the first stage, + # build up the chain in the direction away from XYZ-D65. + if current.NAME != target: + # Start in the chain where the current color resides + start = from_color_index[current.NAME] - 1 - # Get next color in the chain - current = base_space + # Do we need to chromatically adapt away from XYZ D65? + adapt = current.NAME == ABSOLUTE_BASE - count += 1 - if count > color._MAX_CONVERT_ITERATIONS: # pragma: no cover - raise RuntimeError( - 'Conversions reached max iteration of {} and has terminated to avoid an infinite loop'.format( - count - ) - ) + # Moving away from XYZ D65, convert towards are desired target + for index in range(start, -1, -1): + base_space = current + current = from_color[index] - # If we still do not match start converting from the point in the conversion chain - # where are current color resides - if current.NAME != space: - start = from_color_index[current.NAME] - 1 - - # Convert from XYZ, make sure we chromatically adapt from the appropriate white point - if current.NAME == ABSOLUTE_BASE: - coords = cat.chromatic_adaptation( - current.WHITE, - from_color[start].WHITE, - coords, - color.CHROMATIC_ADAPTATION - ) + # Add the conversion chain entry + chain.append((base_space, current, 1, adapt)) - for index in range(start, -1, -1): - current = from_color[index] - coords = current.from_base(coords) + return chain - else: - # Nothing to convert, just pass values as is - coords = color.coords() - return coords +def convert(color: 'Color', space: str) -> Tuple[Type['Space'], Vector]: + """Convert the color coordinates to the specified space.""" + + # Grab the convert for the current space to the desired space + # Result is cached for quicker future conversions. + chain = color._get_convert_chain(color._space, space) # type: ignore[attr-defined] + + # Get coordinates and convert NaN values to 0 + coords = alg.no_nans(color[:-1]) + + # Navigate the conversion chain translated the coordinates along the way. + # Perform chromatic adaption if needed (a conversion to or from XYZ D65). + last = color._space + for a, b, direction, adapt in chain: + if direction and adapt: + coords = color.chromatic_adaptation( + a.WHITE, + b.WHITE, + coords + ) + + coords = b.from_base(coords) if direction else a.to_base(coords) + if not direction and adapt: + coords = color.chromatic_adaptation( + a.WHITE, + b.WHITE, + coords + ) + last = b + + return last, coords diff --git a/lib/coloraide/css/parse.py b/lib/coloraide/css/parse.py index efc8a1f5..0398f2ca 100644 --- a/lib/coloraide/css/parse.py +++ b/lib/coloraide/css/parse.py @@ -4,7 +4,7 @@ from .. import algebra as alg from ..types import Vector from . import color_names -from ..gamut.bounds import Bounds, FLG_ANGLE, FLG_PERCENT, FLG_OPT_PERCENT +from ..channels import Channel, FLG_ANGLE from typing import Optional, Tuple from typing import Dict, Type, TYPE_CHECKING @@ -14,7 +14,6 @@ RGB_CHANNEL_SCALE = 1.0 / 255.0 HUE_SCALE = 1.0 / 360.0 SCALE_PERCENT = 1 / 100.0 -PERCENT_CHANNEL = FLG_PERCENT | FLG_OPT_PERCENT CONVERT_TURN = 360 CONVERT_GRAD = 90 / 100 @@ -24,12 +23,12 @@ RE_SLASH_SPLIT = re.compile(r'(?:\s*/\s*)') COLOR_PARTS = { - "strict_percent": r"(?:[+\-]?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)(?:e[-+]?[0-9]*)?%)", - "strict_float": r"(?:[+\-]?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)(?:e[-+]?[0-9]*)?)", - "strict_angle": r"(?:[+\-]?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)(?:e[-+]?[0-9]*)?(?:deg|rad|turn|grad)?)", - "percent": r"(?:[+\-]?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)(?:e[-+]?[0-9]*)?%|none)", - "float": r"(?:[+\-]?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)(?:e[-+]?[0-9]*)?|none)", - "angle": r"(?:[+\-]?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)(?:e[-+]?[0-9]*)?(?:deg|rad|turn|grad)?|none)", + "strict_percent": r"(?:[+\-]?(?:[0-9]*\.)?[0-9]+(?:e[-+]?[0-9]*)?%)", + "strict_float": r"(?:[+\-]?(?:[0-9]*\.)?[0-9]+(?:e[-+]?[0-9]*)?)", + "strict_angle": r"(?:[+\-]?(?:[0-9]*\.)?[0-9]+(?:e[-+]?[0-9]*)?(?:deg|rad|turn|grad)?)", + "percent": r"(?:[+\-]?(?:[0-9]*\.)?[0-9]+(?:e[-+]?[0-9]*)?%|none)", + "float": r"(?:[+\-]?(?:[0-9]*\.)?[0-9]+(?:e[-+]?[0-9]*)?|none)", + "angle": r"(?:[+\-]?(?:[0-9]*\.)?[0-9]+(?:e[-+]?[0-9]*)?(?:deg|rad|turn|grad)?|none)", "space": r"\s+", "comma": r"\s*,\s*", "slash": r"\s*/\s*", @@ -98,7 +97,7 @@ \b(hwb)\(\s* (?: # Space separated format - {angle}{space}{percent}{space}{percent}(?:{slash}(?:{strict_percent}|{float}))? + {angle}(?:{space}{percent}){{2}}(?:{slash}(?:{strict_percent}|{float}))? ) \s*\) """.format(**COLOR_PARTS) @@ -109,7 +108,8 @@ \b(lab)\(\s* (?: # Space separated format - {percent}{space}{float}{space}{float}(?:{slash}(?:{strict_percent}|{float}))? + (?:{strict_percent}|{float})(?:{space}(?:{strict_percent}|{float})){{2}} + (?:{slash}(?:{strict_percent}|{float}))? ) \s*\) ) @@ -120,7 +120,7 @@ \b(lch)\(\s* (?: # Space separated format - {percent}{space}{float}{space}{angle}(?:{slash}(?:{strict_percent}|{float}))? + (?:(?:{strict_percent}|{float}){space}){{2}}{angle}(?:{slash}(?:{strict_percent}|{float}))? ) \s*\) """.format(**COLOR_PARTS) @@ -131,7 +131,8 @@ \b(oklab)\(\s* (?: # Space separated format - {percent}{space}{float}{space}{float}(?:{slash}(?:{strict_percent}|{float}))? + (?:{strict_percent}|{float})(?:{space}(?:{strict_percent}|{float})){{2}} + (?:{slash}(?:{strict_percent}|{float}))? ) \s*\) ) @@ -142,7 +143,7 @@ \b(oklch)\(\s* (?: # Space separated format - {percent}{space}{float}{space}{angle}(?:{slash}(?:{strict_percent}|{float}))? + (?:(?:{strict_percent}|{float}){space}){{2}}{angle}{angle}(?:{slash}(?:{strict_percent}|{float}))? ) \s*\) """.format(**COLOR_PARTS) @@ -166,24 +167,24 @@ def norm_hex_channel(string: str) -> float: return int(string, 16) * RGB_CHANNEL_SCALE -def norm_percent_channel(string: str, scale: float = 100) -> float: +def norm_percent_channel(string: str, scale: float = 100, offset: float = 0.0) -> float: """Normalize percent channel.""" if string == 'none': # pragma: no cover return norm_float(string) elif string.endswith('%'): value = norm_float(string[:-1]) - return value * scale * 0.01 if scale != 100 else value + return (value * scale * 0.01) - offset if scale != 100 else value else: # pragma: no cover # Should only occur internally if we are doing something wrong. raise ValueError("Unexpected value '{}'".format(string)) -def norm_color_channel(string: str, scale: float = 1) -> float: +def norm_color_channel(string: str, scale: float = 1, offset: float = 0.0) -> float: """Normalize percent channel.""" if string.endswith('%'): - return norm_percent_channel(string, scale) + return norm_percent_channel(string, scale, offset) else: return norm_float(string) @@ -246,20 +247,20 @@ def parse_hex(color: str) -> Tuple[Vector, float]: ) -def parse_rgb_channels(color: str, boundry: Tuple[Bounds, ...]) -> Tuple[Vector, float]: +def parse_rgb_channels(color: str, boundry: Tuple[Channel, ...]) -> Tuple[Vector, float]: """Parse CSS RGB format.""" channels = [] alpha = 1.0 for i, c in enumerate(RE_CHAN_SPLIT.split(color.strip()), 0): c = c.lower() if i <= 2: - channels.append(norm_rgb_channel(c, boundry[i].upper)) + channels.append(norm_rgb_channel(c, boundry[i].high)) elif i == 3: alpha = norm_alpha_channel(c) return channels, alpha -def parse_channels(color: str, boundry: Tuple[Bounds, ...]) -> Tuple[Vector, float]: +def parse_channels(color: str, boundry: Tuple[Channel, ...]) -> Tuple[Vector, float]: """Parse CSS RGB format.""" channels = [] @@ -272,7 +273,7 @@ def parse_channels(color: str, boundry: Tuple[Bounds, ...]) -> Tuple[Vector, flo if bound.flags & FLG_ANGLE: channels.append(norm_angle_channel(c)) else: - channels.append(norm_color_channel(c, bound.upper)) + channels.append(norm_color_channel(c, bound.high)) elif i == length: alpha = norm_alpha_channel(c) return channels, alpha @@ -294,7 +295,7 @@ def parse_color( for space in spaces.values(): if ident in space._serialize(): # Break channels up into a list - num_channels = len(space.CHANNEL_NAMES) + num_channels = len(space.CHANNELS) split = RE_SLASH_SPLIT.split(m.group(2).strip(), maxsplit=1) # Get alpha channel @@ -303,12 +304,11 @@ def parse_color( # Parse color channels channels = [] i = -1 + properties = space.CHANNELS for i, c in enumerate(RE_CHAN_SPLIT.split(split[0]), 0): if c and i < num_channels: - bound = space.BOUNDS[i] - # If the channel is a percentage, force it to scale from 0 - 100, not 0 - 1. - scale = bound.upper if bound.flags & PERCENT_CHANNEL else 1 - channels.append(norm_color_channel(c.lower(), scale)) + channel = properties[i] + channels.append(norm_color_channel(c.lower(), channel.span, channel.offset)) else: # Not the right amount of channels break @@ -343,11 +343,14 @@ def parse_css( return (value[:3], value[3]), m.end(0) else: offset = m.start(0) - return parse_rgb_channels(string[m.end(1) - offset + 1:m.end(0) - offset - 1], cspace.BOUNDS), m.end(0) + return ( + parse_rgb_channels(string[m.end(1) - offset + 1:m.end(0) - offset - 1], cspace.CHANNELS), + m.end(0) + ) else: m = CSS_MATCH[cspace.NAME].match(string, start) if m is not None and (not fullmatch or m.end(0) == len(string)): - return parse_channels(string[m.end(1) + 1:m.end(0) - 1], cspace.BOUNDS), m.end(0) + return parse_channels(string[m.end(1) + 1:m.end(0) - 1], cspace.CHANNELS), m.end(0) # If we wanted to support per color matching of this format, we could enable this. # It is much faster to generically match all `color(space ...)` instances and then diff --git a/lib/coloraide/css/serialize.py b/lib/coloraide/css/serialize.py index 12c3e587..7dd62994 100644 --- a/lib/coloraide/css/serialize.py +++ b/lib/coloraide/css/serialize.py @@ -4,7 +4,7 @@ from .. import algebra as alg from . import parse from .color_names import to_name -from ..gamut.bounds import FLG_PERCENT, FLG_OPT_PERCENT +from ..channels import FLG_PERCENT, FLG_OPT_PERCENT from ..types import Vector from typing import Optional, Union, Match, cast, TYPE_CHECKING @@ -26,7 +26,7 @@ def named_color(obj: 'Color', alpha: Optional[bool], fit: Union[str, bool]) -> O if a is None: a = 1 method = None if not isinstance(fit, str) else fit - coords = alg.no_nans(obj.fit(method=method).coords()) + coords = alg.no_nans(obj.clone().fit(method=method)[:-1]) return to_name(coords + [a]) @@ -49,14 +49,22 @@ def named_color_function( # Iterate the coordinates formatting them for percent, not percent, and even scaling them (sRGB). coords = get_coords(obj, fit, none, legacy) + channels = obj._space.CHANNELS for idx, value in enumerate(coords): - bound = obj._space.BOUNDS[idx] - use_percent = bound.flags & FLG_PERCENT or (percent and bound.flags & FLG_OPT_PERCENT) + channel = channels[idx] + use_percent = channel.flags & FLG_PERCENT or (percent and channel.flags & FLG_OPT_PERCENT) if not use_percent: value *= scale if idx != 0: string.append(COMMA if legacy else SPACE) - string.append(util.fmt_float(value, precision, bound.upper if use_percent else 0)) + string.append( + util.fmt_float( + value, + precision, + channel.span if use_percent else 0.0, + channel.offset if use_percent else 0.0 + ) + ) # Add alpha if needed if a is not None: @@ -91,14 +99,14 @@ def get_coords(obj: 'Color', fit: Union[str, bool], none: bool, legacy: bool) -> """Get the coordinates.""" method = None if not isinstance(fit, str) else fit - coords = obj.fit(method=method).coords() if fit else obj._space.coords() + coords = obj.fit(method=method)[:-1] if fit else obj[:-1] return alg.no_nans(coords) if legacy or not none else coords def get_alpha(obj: 'Color', alpha: Optional[bool], none: bool) -> Optional[float]: """Get the alpha if required.""" - a = alg.no_nan(obj._space.alpha) if not none else obj._space.alpha + a = alg.no_nan(obj[-1]) if not none else obj[-1] alpha = alpha is not False and (alpha is True or a < 1.0 or alg.is_nan(a)) return None if not alpha else a @@ -113,7 +121,7 @@ def hexadecimal( """Get the hex `RGB` value.""" method = None if not isinstance(fit, str) else fit - coords = [c for c in alg.no_nans(obj.fit(method=method).coords())] + coords = [c for c in alg.no_nans(obj.fit(method=method)[:-1])] a = get_alpha(obj, alpha, False) if a is not None: diff --git a/lib/coloraide/distance/__init__.py b/lib/coloraide/distance/__init__.py index fb3b9808..b2554f0e 100644 --- a/lib/coloraide/distance/__init__.py +++ b/lib/coloraide/distance/__init__.py @@ -2,7 +2,7 @@ from abc import ABCMeta, abstractmethod import math from .. import algebra as alg -from ..types import ColorInput +from ..types import ColorInput, Plugin from typing import TYPE_CHECKING, Any, Sequence, Optional if TYPE_CHECKING: # pragma: no cover @@ -35,20 +35,20 @@ def closest(color: 'Color', colors: Sequence[ColorInput], method: Optional[str] return closest -def distance_euclidean(color: 'Color', sample: 'Color', space: str = "lab") -> float: +def distance_euclidean(color: 'Color', sample: 'Color', space: str = "lab-d65") -> float: """ Euclidean distance. https://en.wikipedia.org/wiki/Euclidean_distance """ - coords1 = alg.no_nans(color.convert(space).coords()) - coords2 = alg.no_nans(sample.convert(space).coords()) + coords1 = alg.no_nans(color.convert(space)[:-1]) + coords2 = alg.no_nans(sample.convert(space)[:-1]) return math.sqrt(sum((x - y) ** 2.0 for x, y in zip(coords1, coords2))) -class DeltaE(ABCMeta): +class DeltaE(Plugin, metaclass=ABCMeta): """Delta E plugin class.""" NAME = '' diff --git a/lib/coloraide/distance/delta_e_2000.py b/lib/coloraide/distance/delta_e_2000.py index 1662ca20..bb62e4c3 100644 --- a/lib/coloraide/distance/delta_e_2000.py +++ b/lib/coloraide/distance/delta_e_2000.py @@ -13,6 +13,15 @@ class DE2000(DeltaE): NAME = "2000" + # CSS uses D50 because that is the only Lab in the spec, + # but most implementation use D65. Typically D50 is the + # choice for reflective (i.e. Paper) or transmissive readings, + # while displays would typically use a measured white reference, + # or D65. If a CSS compliant variant is desired, simply subclass + # and set `LAB` to `lab-d50`. If the intent is not to override, + # then set `NAME` to something like `NAME="2000-D50"`. + LAB = "lab-d65" + G_CONST = 25 ** 7 @classmethod @@ -34,8 +43,8 @@ def distance( http://www2.ece.rochester.edu/~gsharma/ciede2000/ciede2000noteCRNA.pdf """ - l1, a1, b1 = alg.no_nans(color.convert("lab").coords()) - l2, a2, b2 = alg.no_nans(sample.convert("lab").coords()) + l1, a1, b1 = alg.no_nans(color.convert(cls.LAB)[:-1]) + l2, a2, b2 = alg.no_nans(sample.convert(cls.LAB)[:-1]) # Equation (2) c1 = math.sqrt(a1 ** 2 + b1 ** 2) diff --git a/lib/coloraide/distance/delta_e_76.py b/lib/coloraide/distance/delta_e_76.py index f0be6bba..17c2c251 100644 --- a/lib/coloraide/distance/delta_e_76.py +++ b/lib/coloraide/distance/delta_e_76.py @@ -10,7 +10,7 @@ class DE76(DeltaE): """Delta E 76 class.""" NAME = "76" - SPACE = "lab" + SPACE = "lab-d65" @classmethod def distance(cls, color: 'Color', sample: 'Color', **kwargs: Any) -> float: diff --git a/lib/coloraide/distance/delta_e_94.py b/lib/coloraide/distance/delta_e_94.py index bcf2fd8c..8afb44be 100644 --- a/lib/coloraide/distance/delta_e_94.py +++ b/lib/coloraide/distance/delta_e_94.py @@ -29,8 +29,8 @@ def distance( http://www.brucelindbloom.com/Eqn_DeltaE_CIE94.html """ - l1, a1, b1 = alg.no_nans(color.convert("lab").coords()) - l2, a2, b2 = alg.no_nans(sample.convert("lab").coords()) + l1, a1, b1 = alg.no_nans(color.convert("lab")[:-1]) + l2, a2, b2 = alg.no_nans(sample.convert("lab")[:-1]) # Equation (5) c1 = math.sqrt(a1 ** 2 + b1 ** 2) diff --git a/lib/coloraide/distance/delta_e_cmc.py b/lib/coloraide/distance/delta_e_cmc.py index f28e8ab0..ff47b001 100644 --- a/lib/coloraide/distance/delta_e_cmc.py +++ b/lib/coloraide/distance/delta_e_cmc.py @@ -28,8 +28,8 @@ def distance( http://www.brucelindbloom.com/index.html?Eqn_DeltaE_CMC.html """ - l1, a1, b1 = alg.no_nans(color.convert("lab").coords()) - l2, a2, b2 = alg.no_nans(sample.convert("lab").coords()) + l1, a1, b1 = alg.no_nans(color.convert("lab")[:-1]) + l2, a2, b2 = alg.no_nans(sample.convert("lab")[:-1]) # Equation (3) c1 = math.sqrt(a1 ** 2 + b1 ** 2) diff --git a/lib/coloraide/distance/delta_e_hyab.py b/lib/coloraide/distance/delta_e_hyab.py index 8ad7935c..3c7d1da3 100644 --- a/lib/coloraide/distance/delta_e_hyab.py +++ b/lib/coloraide/distance/delta_e_hyab.py @@ -15,7 +15,7 @@ class DEHyAB(DeltaE): NAME = "hyab" @classmethod - def distance(cls, color: 'Color', sample: 'Color', space: str = "lab", **kwargs: Any) -> float: + def distance(cls, color: 'Color', sample: 'Color', space: str = "lab-d65", **kwargs: Any) -> float: """ HyAB distance for Lab-ish spaces. @@ -25,8 +25,8 @@ def distance(cls, color: 'Color', sample: 'Color', space: str = "lab", **kwargs: color = color.convert(space) sample = sample.convert(space) - if not isinstance(color._space, Labish): - raise ValueError("The space '{}' is not a 'lab-sh' color space and cannot use HyAB".format(space)) + if not issubclass(color._space, Labish): + raise ValueError("The space '{}' is not a 'lab-ish' color space and cannot use HyAB".format(space)) names = color._space.labish_names() l1, a1, b1 = alg.no_nans([color.get(names[0]), color.get(names[1]), color.get(names[2])]) diff --git a/lib/coloraide/distance/delta_e_itp.py b/lib/coloraide/distance/delta_e_itp.py index d2a1bd79..da09bb8d 100644 --- a/lib/coloraide/distance/delta_e_itp.py +++ b/lib/coloraide/distance/delta_e_itp.py @@ -21,8 +21,8 @@ class DEITP(DeltaE): def distance(cls, color: 'Color', sample: 'Color', scalar: float = 720, **kwargs: Any) -> float: """Delta E ITP color distance formula.""" - i1, t1, p1 = alg.no_nans(color.convert('ictcp').coords()) - i2, t2, p2 = alg.no_nans(sample.convert('ictcp').coords()) + i1, t1, p1 = alg.no_nans(color.convert('ictcp')[:-1]) + i2, t2, p2 = alg.no_nans(sample.convert('ictcp')[:-1]) # Equation (1) return scalar * math.sqrt((i1 - i2) ** 2 + 0.25 * (t1 - t2) ** 2 + (p1 - p2) ** 2) diff --git a/lib/coloraide/distance/delta_e_z.py b/lib/coloraide/distance/delta_e_z.py index 07a64308..e349ca57 100644 --- a/lib/coloraide/distance/delta_e_z.py +++ b/lib/coloraide/distance/delta_e_z.py @@ -21,8 +21,8 @@ class DEZ(DeltaE): def distance(cls, color: 'Color', sample: 'Color', **kwargs: Any) -> float: """Delta E z color distance formula.""" - jz1, az1, bz1 = alg.no_nans(color.convert('jzazbz').coords()) - jz2, az2, bz2 = alg.no_nans(sample.convert('jzazbz').coords()) + jz1, az1, bz1 = alg.no_nans(color.convert('jzazbz')[:-1]) + jz2, az2, bz2 = alg.no_nans(sample.convert('jzazbz')[:-1]) cz1 = math.sqrt(az1 ** 2 + bz1 ** 2) cz2 = math.sqrt(az2 ** 2 + bz2 ** 2) diff --git a/lib/coloraide/filters/__init__.py b/lib/coloraide/filters/__init__.py new file mode 100644 index 00000000..78a13d14 --- /dev/null +++ b/lib/coloraide/filters/__init__.py @@ -0,0 +1,49 @@ +"""Provides a plugin system for filtering colors.""" +from abc import ABCMeta, abstractmethod +from ..types import Plugin +from typing import Optional, Tuple, Any, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from ..color import Color + + +class Filter(Plugin, metaclass=ABCMeta): + """Filter a color.""" + + NAME = '' + DEFAULT_SPACE = 'srgb-linear' + ALLOWED_SPACES = ('srgb-linear',) # type: Tuple[str, ...] + + @classmethod + @abstractmethod + def filter(cls, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + """Filter the given color.""" + + +def filters( + color: 'Color', + name: str, + amount: Optional[float] = None, + space: Optional[str] = None, + in_place: bool = False, + **kwargs: Any +) -> 'Color': + """Filter.""" + + try: + f = color.FILTER_MAP[name] + except KeyError: + raise ValueError("'{}' filter is not supported".format(name)) + + if space is None: + space = f.DEFAULT_SPACE + + if space not in f.ALLOWED_SPACES: + raise ValueError( + "The '{}' only supports filtering in the {} spaces, not '{}'".format(name, str(f.ALLOWED_SPACES), space) + ) + + current = color.space() + c = color.convert(space, in_place=in_place) + f.filter(c, amount, **kwargs) + return c.convert(current, in_place=True) diff --git a/lib/coloraide/filters/cvd.py b/lib/coloraide/filters/cvd.py new file mode 100644 index 00000000..7ec66419 --- /dev/null +++ b/lib/coloraide/filters/cvd.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +"""Color vision deficiency.""" +from .. import algebra as alg +from ..filters import Filter +from ..types import Vector, Matrix +from typing import Any, Optional, Dict, Tuple, Callable, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from ..color import Color + +LRGB_TO_LMS = [ + [0.1788315947640612, 0.4399813067603072, 0.03597439330845842], + [0.033798905547214174, 0.27515876526029825, 0.03621503435966088], + [0.00031083956494671645, 0.001916652059097586, 0.015284557008174545] +] + +INV_LMS_TO_LRGB = [ + [8.006605439487906, -12.884009114492532, 11.682514549178705], + [-0.9781986265569573, 5.269343182653304, -10.182783982803086], + [-0.04016494160643852, -0.39874480300255594, 66.46482888609172] +] + +BRETTEL_PROTAN = ( + [ + [0.0, 4.601963481151596, -33.603845913662], + [0.0, 3.1330128106441544, -4.649970365182458], + [0.0, -0.48646275957526675, 66.69200681278052] + ], + [ + [0.0, 4.459087290426133, -30.787672758115008], + [0.0, 3.150468559474495, -4.994033368376835], + [0.0, -0.4857460246367451, 66.67787954858585] + ], + [0.0, 0.01751204863221885, -0.3451727051671733] +) # type: Tuple[Matrix, Matrix, Vector] + +BRETTEL_DEUTAN = ( + [ + [2.0585800667377834, 0.0, -19.868153658269534], + [1.4544437807582153, 0.0, 2.720909033066217], + [-0.2242492731553509, 0.0, 65.48837312087291] + ], + [ + [2.107180806727998, 0.0, -21.685368064956243], + [1.4345668949485586, 0.0, 3.464119182966142], + [-0.22274513788806224, 0.0, 65.4321324900127] + ], + [-0.01751204863221885, 0.0, 0.6547872948328268] +) # type: Tuple[Matrix, Matrix, Vector] + +BRETTEL_TRITAN = ( + [ + [7.282866078566971, -10.918384106846926, 0.0], + [-0.347368547887983, 3.5560532261295186, 0.0], + [-4.157704241835348, 10.784201234635589, 0.0] + ], + [ + [7.981703886196052, -12.244068643121977, 0.0], + [-0.9564937840814959, 4.711554398878355, 0.0], + [-0.18183628056123033, 3.2420410695833217, 0.0] + ], + [0.3451727051671733, -0.6547872948328268, 0.0] +) # type: Tuple[Matrix, Matrix, Vector] + +VIENOT_PROTAN = [ + [0.10887256075552479, 0.8911274392444744, -8.326672684688674e-17], + [0.10887256075552504, 0.8911274392444752, 0.0], + [0.004470319142320402, -0.004470319142320406, 1.0000000000000002] +] + +VIENOT_DEUTAN = [ + [0.2902683260039952, 0.7097316739960047, 2.7755575615628914e-17], + [0.29026832600399544, 0.709731673996005, 0.0], + [-0.021965353642437093, 0.021965353642437083, 1.0000000000000002] +] + +VIENOT_TRITAN = [ + [1.0000000000000002, 0.15241850478972624, -0.15241850478972596], + [-3.0357660829594124e-18, 0.8671480610854684, 0.1328519389145317], + [-3.469446951953614e-18, 0.8671480610854685, 0.13285193891453168] +] + +MACHADO_PROTAN = { + 0: [[1.000000, 0.000000, -0.000000], [0.000000, 1.000000, 0.000000], [-0.000000, -0.000000, 1.000000]], + 1: [[0.856167, 0.182038, -0.038205], [0.029342, 0.955115, 0.015544], [-0.002880, -0.001563, 1.004443]], + 2: [[0.734766, 0.334872, -0.069637], [0.051840, 0.919198, 0.028963], [-0.004928, -0.004209, 1.009137]], + 3: [[0.630323, 0.465641, -0.095964], [0.069181, 0.890046, 0.040773], [-0.006308, -0.007724, 1.014032]], + 4: [[0.539009, 0.579343, -0.118352], [0.082546, 0.866121, 0.051332], [-0.007136, -0.011959, 1.019095]], + 5: [[0.458064, 0.679578, -0.137642], [0.092785, 0.846313, 0.060902], [-0.007494, -0.016807, 1.024301]], + 6: [[0.385450, 0.769005, -0.154455], [0.100526, 0.829802, 0.069673], [-0.007442, -0.022190, 1.029632]], + 7: [[0.319627, 0.849633, -0.169261], [0.106241, 0.815969, 0.077790], [-0.007025, -0.028051, 1.035076]], + 8: [[0.259411, 0.923008, -0.182420], [0.110296, 0.804340, 0.085364], [-0.006276, -0.034346, 1.040622]], + 9: [[0.203876, 0.990338, -0.194214], [0.112975, 0.794542, 0.092483], [-0.005222, -0.041043, 1.046265]], + 10: [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]] +} # type: Dict[int, Matrix] + +MACHADO_DEUTAN = { + 0: [[1.000000, 0.000000, -0.000000], [0.000000, 1.000000, 0.000000], [-0.000000, -0.000000, 1.000000]], + 1: [[0.866435, 0.177704, -0.044139], [0.049567, 0.939063, 0.011370], [-0.003453, 0.007233, 0.996220]], + 2: [[0.760729, 0.319078, -0.079807], [0.090568, 0.889315, 0.020117], [-0.006027, 0.013325, 0.992702]], + 3: [[0.675425, 0.433850, -0.109275], [0.125303, 0.847755, 0.026942], [-0.007950, 0.018572, 0.989378]], + 4: [[0.605511, 0.528560, -0.134071], [0.155318, 0.812366, 0.032316], [-0.009376, 0.023176, 0.986200]], + 5: [[0.547494, 0.607765, -0.155259], [0.181692, 0.781742, 0.036566], [-0.010410, 0.027275, 0.983136]], + 6: [[0.498864, 0.674741, -0.173604], [0.205199, 0.754872, 0.039929], [-0.011131, 0.030969, 0.980162]], + 7: [[0.457771, 0.731899, -0.189670], [0.226409, 0.731012, 0.042579], [-0.011595, 0.034333, 0.977261]], + 8: [[0.422823, 0.781057, -0.203881], [0.245752, 0.709602, 0.044646], [-0.011843, 0.037423, 0.974421]], + 9: [[0.392952, 0.823610, -0.216562], [0.263559, 0.690210, 0.046232], [-0.011910, 0.040281, 0.971630]], + 10: [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.011820, 0.042940, 0.968881]], + +} # type: Dict[int, Matrix] + +MACHADO_TRITAN = { + 0: [[1.000000, 0.000000, -0.000000], [0.000000, 1.000000, 0.000000], [-0.000000, -0.000000, 1.000000]], + 1: [[0.926670, 0.092514, -0.019184], [0.021191, 0.964503, 0.014306], [0.008437, 0.054813, 0.936750]], + 2: [[0.895720, 0.133330, -0.029050], [0.029997, 0.945400, 0.024603], [0.013027, 0.104707, 0.882266]], + 3: [[0.905871, 0.127791, -0.033662], [0.026856, 0.941251, 0.031893], [0.013410, 0.148296, 0.838294]], + 4: [[0.948035, 0.089490, -0.037526], [0.014364, 0.946792, 0.038844], [0.010853, 0.193991, 0.795156]], + 5: [[1.017277, 0.027029, -0.044306], [-0.006113, 0.958479, 0.047634], [0.006379, 0.248708, 0.744913]], + 6: [[1.104996, -0.046633, -0.058363], [-0.032137, 0.971635, 0.060503], [0.001336, 0.317922, 0.680742]], + 7: [[1.193214, -0.109812, -0.083402], [-0.058496, 0.979410, 0.079086], [-0.002346, 0.403492, 0.598854]], + 8: [[1.257728, -0.139648, -0.118081], [-0.078003, 0.975409, 0.102594], [-0.003316, 0.501214, 0.502102]], + 9: [[1.278864, -0.125333, -0.153531], [-0.084748, 0.957674, 0.127074], [-0.000989, 0.601151, 0.399838]], + 10: [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.303900]], +} # type: Dict[int, Matrix] + + +def brettel(color: 'Color', severity: float, wings: Tuple[Matrix, Matrix, Vector]) -> None: + """ + Calculate color blindness using Brettel 1997. + + https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.496.7153&rep=rep1&type=pdf + + Probably the only accurate approach for tritanopia, but is more expensive to calculate. + """ + + w1, w2, sep = wings + + # Which side are we on? + lms_c = alg.dot(LRGB_TO_LMS, color[:-1], dims=alg.D2_D1) + if alg.dot(lms_c, sep) > 0: + coords = alg.dot(w2, lms_c, dims=alg.D2_D1) + else: + coords = alg.dot(w1, lms_c, dims=alg.D2_D1) + + if severity < 1: + color[:-1] = [alg.lerp(a, b, severity) for a, b in zip(color[:-1], coords)] + else: + color[:-1] = coords + + +def vienot(color: 'Color', severity: float, transform: Matrix) -> None: + """ + Calculate color blindness using the Viénot, Brettel, and Mollon 1999 approach, best for protanopia and deuteranopia. + + Can be used for tritanopia, but will be not be accurate. Based on + http://vision.psychol.cam.ac.uk/jdmollon/papers/colourmaps.pdf. Tritanopia is inferred from the paper as they do not + actually go through the logic, the difference is we use LMS red instead of LMS blue. + + Covered here as well: https://ixora.io/projects/colorblindness/color-blindness-simulation-research/. Though they use + Hunt-Pointer-Estevez transformation, but here they argue that Smith and Pokorny is still probably the way to go: + https://daltonlens.org/understanding-cvd-simulation/#From-CIE-XYZ-to-LMS. + + Our matrices are precalculated, so all we need to do is dot and go unless we want something lower than severity 1, + then we interpolate against the original color. + """ + + coords = alg.dot(transform, color[:-1], dims=alg.D2_D1) + if severity < 1: + color[:-1] = [alg.lerp(c1, c2, severity) for c1, c2 in zip(color[:-1], coords)] + else: + color[:-1] = coords + + +def machado(color: 'Color', severity: float, matrices: Dict[int, Matrix]) -> None: + """ + Machado approach to protanopia, deuteranopia, and tritanopia. + + https://www.inf.ufrgs.br/~oliveira/pubs_files/CVD_Simulation/CVD_Simulation.html#Reference + + Decent results for protanopia and deuteranopia, but tritanopia is really only an approximation. + They don't even bother to show tritanopia results. + """ + + # Calculate the approximate severity + severity *= 10 + severity1 = int(severity) + + # Filter the color according to the severity + m1 = matrices[severity1] + coords = alg.dot(m1, color[:-1], dims=alg.D2_D1) + + # If severity was not exact, and it also isn't max severity, + # let's calculate the next most severity and interpolate + # between the two results. + if severity1 != severity and severity1 < 10: + # Calculate next highest severity + severity2 = severity1 + 1 + # Calculate the weight + weight = (severity - severity1) + # Get the next severity in the list + m2 = matrices[severity2] + + # It is actually stated that the two matrices should be interpolated, + # but it ends up being faster just modifying the color on both the high + # and low matrix and interpolating the color than interpolating the matrix + # and then applying it to the color. The results are identical as well. + coords2 = alg.dot(m2, color[:-1], dims=alg.D2_D1) + coords = [alg.lerp(c1, c2, weight) for c1, c2 in zip(coords, coords2)] + + # Return the altered color + color[:-1] = coords + + +class Protan(Filter): + """Protanopia filter.""" + + NAME = "protan" + + ALLOWED_SPACES = ('srgb-linear',) + + BRETTEL = BRETTEL_PROTAN + VIENOT = VIENOT_PROTAN + MACHADO = MACHADO_PROTAN + + @classmethod + def brettel(cls, color: 'Color', severity: float) -> None: + """Tritanopia vision deficiency using Brettel method.""" + + brettel(color, severity, cls.BRETTEL) + + @classmethod + def vienot(cls, color: 'Color', severity: float) -> None: + """Tritanopia vision deficiency using Viénot method.""" + + vienot(color, severity, cls.VIENOT) + + @classmethod + def machado(cls, color: 'Color', severity: float) -> None: + """Tritanopia vision deficiency using Machado method.""" + + machado(color, severity, cls.MACHADO) + + @classmethod + def select_filter(cls, method: str) -> Callable[..., None]: + """Select the best filter.""" + + if method == 'brettel': + return cls.brettel + elif method == 'vienot': + return cls.vienot + elif method == 'machado': + return cls.machado + else: + raise ValueError("Unrecognized CVD filter method '{}'".format(method)) + + @classmethod + def get_best_filter(cls, method: Optional[str], max_severity: bool) -> Callable[..., None]: + """Get the best filter based on the situation.""" + + if method is None: + method = 'vienot' if max_severity else 'machado' + return cls.select_filter(method) + + @classmethod + def filter(cls, color: 'Color', amount: Optional[float] = None, **kwargs: Any) -> None: # noqa: A003 + """Filter the color.""" + + method = kwargs.get('method') # type: Optional[str] + amount = alg.clamp(1 if amount is None else amount, 0, 1) + cls.get_best_filter(method, amount == 1)(color, amount) + + +class Deutan(Protan): + """Deuteranopia filter.""" + + NAME = 'deutan' + + BRETTEL = BRETTEL_DEUTAN + VIENOT = VIENOT_DEUTAN + MACHADO = MACHADO_DEUTAN + + +class Tritan(Protan): + """Deuteranopia filter.""" + + NAME = 'tritan' + + BRETTEL = BRETTEL_TRITAN + VIENOT = VIENOT_TRITAN + MACHADO = MACHADO_TRITAN + + @classmethod + def get_best_filter(cls, method: Optional[str], amount: float) -> Callable[..., None]: + """Get the best filter based on the situation.""" + + return cls.select_filter('brettel' if method is None else method) diff --git a/lib/coloraide/filters/w3c_filter_effects.py b/lib/coloraide/filters/w3c_filter_effects.py new file mode 100644 index 00000000..39c1cd8d --- /dev/null +++ b/lib/coloraide/filters/w3c_filter_effects.py @@ -0,0 +1,163 @@ +"""Provide filters as described by the https://www.w3.org/TR/filter-effects-1/.""" +import math +from ..filters import Filter +from .. import algebra as alg +from typing import Any, Optional, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from ..color import Color + + +def linear_transfer(value: float, slope: float = 1.0, intercept: float = 0.0) -> float: + """ + Linear transfer function. + + https://drafts.fxtf.org/filter-effects-1/#feFuncRElement + """ + + return value * slope + intercept + + +class Sepia(Filter): + """Sepia filter.""" + + NAME = 'sepia' + ALLOWED_SPACES = ('srgb-linear', 'srgb') + + @classmethod + def filter(cls, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + """Apply a sepia filter to the color.""" + + amount = 1 - alg.clamp(1 if amount is None else amount, 0, 1) + + m = [ + [0.393 + 0.607 * amount, 0.769 - 0.769 * amount, 0.189 - 0.189 * amount], + [0.349 - 0.349 * amount, 0.686 + 0.314 * amount, 0.168 - 0.168 * amount], + [0.272 - 0.272 * amount, 0.534 - 0.534 * amount, 0.131 + 0.869 * amount] + ] + + color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + + +class Grayscale(Filter): + """Grayscale filter.""" + + NAME = 'grayscale' + ALLOWED_SPACES = ('srgb-linear', 'srgb') + + @classmethod + def filter(cls, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + """Apply a grayscale filter to the color.""" + + amount = 1 - alg.clamp(1 if amount is None else amount, 0, 1) + + m = [ + [0.2126 + 0.7874 * amount, 0.7152 - 0.7152 * amount, 0.0722 - 0.0722 * amount], + [0.2126 - 0.2126 * amount, 0.7152 + 0.2848 * amount, 0.0722 - 0.0722 * amount], + [0.2126 - 0.2126 * amount, 0.7152 - 0.7152 * amount, 0.0722 + 0.9278 * amount] + ] + + color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + + +class Saturate(Filter): + """Saturation filter.""" + + NAME = 'saturate' + ALLOWED_SPACES = ('srgb-linear', 'srgb') + + @classmethod + def filter(cls, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + """Apply a saturation filter to the color.""" + + amount = alg.clamp(1 if amount is None else amount, 0) + + m = [ + [0.213 + 0.787 * amount, 0.715 - 0.715 * amount, 0.072 - 0.072 * amount], + [0.213 - 0.213 * amount, 0.715 + 0.285 * amount, 0.072 - 0.072 * amount], + [0.213 - 0.213 * amount, 0.715 - 0.715 * amount, 0.072 + 0.928 * amount] + ] + + color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) + + +class Invert(Filter): + """Grayscale filter.""" + + NAME = 'invert' + ALLOWED_SPACES = ('srgb-linear', 'srgb') + + @classmethod + def filter(cls, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + """Apply an invert filter.""" + + amount = alg.clamp(1 if amount is None else amount, 0, 1) + for e, c in enumerate(color[:-1]): + color[e] = alg.lerp(amount, 1 - amount, c) + + +class Opacity(Filter): + """Opacity filter.""" + + NAME = 'opacity' + ALLOWED_SPACES = ('srgb-linear', 'srgb') + + @classmethod + def filter(cls, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + """Apply an opacity filter.""" + + amount = alg.clamp(1 if amount is None else amount, 0, 1) + color[-1] = alg.lerp(0, amount, color[-1]) + + +class Brightness(Filter): + """Brightness filter.""" + + NAME = 'brightness' + ALLOWED_SPACES = ('srgb-linear', 'srgb') + + @classmethod + def filter(cls, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + """Apply a brightness filter.""" + + amount = alg.clamp(1 if amount is None else amount, 0) + for e, c in enumerate(color[:-1]): + color[e] = linear_transfer(c, amount) + + +class Contrast(Filter): + """Contrast filter.""" + + NAME = 'contrast' + ALLOWED_SPACES = ('srgb-linear', 'srgb') + + @classmethod + def filter(cls, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + """Apply a contrast filter.""" + + amount = alg.clamp(1 if amount is None else amount, 0) + for e, c in enumerate(color[:-1]): + color[e] = linear_transfer(c, amount, (1 - amount) * 0.5) + + +class HueRotate(Filter): + """Hue rotate filter.""" + + NAME = 'hue-rotate' + ALLOWED_SPACES = ('srgb-linear', 'srgb') + + @classmethod + def filter(cls, color: 'Color', amount: Optional[float], **kwargs: Any) -> None: # noqa: A003 + """Apply a hue rotation filter.""" + + rad = math.radians(0 if amount is None else amount) + cos = math.cos(rad) + sin = math.sin(rad) + + m = [ + [0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928], + [0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.140, 0.072 - cos * 0.072 - sin * 0.283], + [0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072] + ] + + color[:-1] = alg.dot(m, color[:-1], dims=alg.D2_D1) diff --git a/lib/coloraide/gamut/__init__.py b/lib/coloraide/gamut/__init__.py index d8664ac0..bcadd761 100644 --- a/lib/coloraide/gamut/__init__.py +++ b/lib/coloraide/gamut/__init__.py @@ -1,7 +1,8 @@ """Gamut handling.""" from .. import algebra as alg -from .bounds import FLG_ANGLE, GamutBound +from ..channels import FLG_ANGLE from abc import ABCMeta, abstractmethod +from ..types import Plugin from typing import TYPE_CHECKING, Optional, Any if TYPE_CHECKING: # pragma: no cover @@ -11,47 +12,43 @@ def clip_channels(color: 'Color') -> None: """Clip channels.""" - channels = alg.no_nans(color.coords()) - fit = [] + channels = alg.no_nans(color[:-1]) for i, value in enumerate(channels): - bounds = color._space.BOUNDS[i] - a = bounds.lower # type: Optional[float] - b = bounds.upper # type: Optional[float] - is_bound = isinstance(bounds, GamutBound) + chan = color._space.CHANNELS[i] + a = chan.low # type: Optional[float] + b = chan.high # type: Optional[float] # Wrap the angle. Not technically out of gamut, but we will clean it up. - if bounds.flags & FLG_ANGLE: - fit.append(value % 360.0) + if chan.flags & FLG_ANGLE: + color[i] = value % 360.0 continue # These parameters are unbounded - if not is_bound: # pragma: no cover + if not chan.bound: # pragma: no cover # Will not execute unless we have a space that defines some coordinates # as bound and others as not. We do not currently have such spaces. a = b = None # Fit value in bounds. - fit.append(alg.clamp(value, a, b)) - color.update(color.space(), fit, color.alpha) + color[i] = alg.clamp(value, a, b) def verify(color: 'Color', tolerance: float) -> bool: """Verify the values are in bound.""" - channels = alg.no_nans(color.coords()) + channels = alg.no_nans(color[:-1]) for i, value in enumerate(channels): - bounds = color._space.BOUNDS[i] - a = bounds.lower # type: Optional[float] - b = bounds.upper # type: Optional[float] - is_bound = isinstance(bounds, GamutBound) + chan = color._space.CHANNELS[i] + a = chan.low # type: Optional[float] + b = chan.high # type: Optional[float] # Angles will wrap, so no sense checking them - if bounds.flags & FLG_ANGLE: + if chan.flags & FLG_ANGLE: continue # These parameters are unbounded - if not is_bound: + if not chan.bound: a = b = None # Check if bounded values are in bounds @@ -60,7 +57,7 @@ def verify(color: 'Color', tolerance: float) -> bool: return True -class Fit(ABCMeta): +class Fit(Plugin, metaclass=ABCMeta): """Fit plugin class.""" NAME = '' diff --git a/lib/coloraide/gamut/bounds.py b/lib/coloraide/gamut/bounds.py deleted file mode 100644 index f351a515..00000000 --- a/lib/coloraide/gamut/bounds.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Channel boundary objects.""" -from typing import Any - -FLG_ANGLE = 0x1 -FLG_PERCENT = 0x2 -FLG_OPT_PERCENT = 0x4 - - -class Bounds: - """Immutable.""" - - __slots__ = ('lower', 'upper', 'flags') - - def __init__(self, lower: float, upper: float, flags: int = 0) -> None: - """Initialize.""" - - self.lower = lower - self.upper = upper - self.flags = flags - - def __setattr__(self, name: str, value: Any) -> None: - """Prevent mutability.""" - - if not hasattr(self, name) and name in self.__slots__: - super().__setattr__(name, value) - return - - raise AttributeError("'{}' is immutable".format(self.__class__.__name__)) # pragma: no cover - - def __repr__(self) -> str: # pragma: no cover - """Representation.""" - - return "{}({})".format( - self.__class__.__name__, ', '.join(["{}={!r}".format(k, getattr(self, k)) for k in self.__slots__]) - ) - - __str__ = __repr__ - - -class GamutBound(Bounds): - """Bounded gamut value.""" - - -class GamutUnbound(Bounds): - """Unbounded gamut value.""" diff --git a/lib/coloraide/gamut/fit_css_color_4.py b/lib/coloraide/gamut/fit_css_color_4.py deleted file mode 100644 index 5e5327b6..00000000 --- a/lib/coloraide/gamut/fit_css_color_4.py +++ /dev/null @@ -1,53 +0,0 @@ -"""CSS Color Level 4 gamut mapping.""" -from ..gamut import Fit, clip_channels -from ..algebra import NaN -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: # pragma: no cover - from ..color import Color - - -class CssColor4(Fit): - """Uses the CSS Color Level 4 algorithm for gamut mapping: Oklch: https://www.w3.org/TR/css-color-4/#binsearch.""" - - NAME = "css-color-4" - LIMIT = 0.02 - DE = "ok" - SPACE = "oklch" - MIN_LIGHTNESS = 0 - MAX_LIGHTNESS = 100 - - @classmethod - def fit(cls, color: 'Color', **kwargs: Any) -> None: - """Gamut mapping via Oklch chroma.""" - - space = color.space() - mapcolor = color.convert(cls.SPACE) - lightness = mapcolor.lightness - - # Return white or black if lightness is out of range - if lightness >= cls.MAX_LIGHTNESS or lightness <= cls.MIN_LIGHTNESS: - mapcolor.chroma = 0 - mapcolor.hue = NaN - clip_channels(color.update(mapcolor)) - return - - # Set initial chroma boundaries - low = 0.0 - high = mapcolor.chroma - clip_channels(color.update(mapcolor)) - - # Adjust chroma (using binary search). - # This helps preserve the other attributes of the color. - # Compress chroma until we are are right outside the gamut, but under the JND. - if not mapcolor.in_gamut(space): - while True: - mapcolor.chroma = (high + low) * 0.5 - - if mapcolor.in_gamut(space, tolerance=0): - low = mapcolor.chroma - else: - clip_channels(color.update(mapcolor)) - if mapcolor.delta_e(color, method=cls.DE) < cls.LIMIT: - break - high = mapcolor.chroma diff --git a/lib/coloraide/gamut/fit_lch_chroma.py b/lib/coloraide/gamut/fit_lch_chroma.py index e3e97d39..26717b4f 100644 --- a/lib/coloraide/gamut/fit_lch_chroma.py +++ b/lib/coloraide/gamut/fit_lch_chroma.py @@ -35,7 +35,7 @@ class LchChroma(Fit): EPSILON = 0.1 LIMIT = 2.0 DE = "2000" - SPACE = "lch" + SPACE = "lch-d65" MIN_LIGHTNESS = 0 MAX_LIGHTNESS = 100 @@ -45,18 +45,18 @@ def fit(cls, color: 'Color', **kwargs: Any) -> None: space = color.space() mapcolor = color.convert(cls.SPACE) - lightness = mapcolor.lightness + lightness = mapcolor['lightness'] # Return white or black if lightness is out of range if lightness >= cls.MAX_LIGHTNESS or lightness <= cls.MIN_LIGHTNESS: - mapcolor.chroma = 0 - mapcolor.hue = NaN + mapcolor['chroma'] = 0 + mapcolor['hue'] = NaN clip_channels(color.update(mapcolor)) return # Set initial chroma boundaries low = 0.0 - high = mapcolor.chroma + high = mapcolor['chroma'] clip_channels(color.update(mapcolor)) # Adjust chroma if we are not under the JND yet. @@ -65,11 +65,11 @@ def fit(cls, color: 'Color', **kwargs: Any) -> None: lower_in_gamut = True while True: - mapcolor.chroma = (high + low) * 0.5 + mapcolor['chroma'] = (high + low) * 0.5 # Avoid doing expensive delta E checks if in gamut if lower_in_gamut and mapcolor.in_gamut(space, tolerance=0): - low = mapcolor.chroma + low = mapcolor['chroma'] else: clip_channels(color.update(mapcolor)) de = mapcolor.delta_e(color, method=cls.DE) @@ -84,7 +84,7 @@ def fit(cls, color: 'Color', **kwargs: Any) -> None: # chroma to get as close to the JND as possible. if lower_in_gamut: lower_in_gamut = False - low = mapcolor.chroma + low = mapcolor['chroma'] else: # We are still outside the gamut and outside the JND - high = mapcolor.chroma + high = mapcolor['chroma'] diff --git a/lib/coloraide/gamut/fit_oklch_chroma.py b/lib/coloraide/gamut/fit_oklch_chroma.py index a13a50f6..424f6dde 100644 --- a/lib/coloraide/gamut/fit_oklch_chroma.py +++ b/lib/coloraide/gamut/fit_oklch_chroma.py @@ -7,7 +7,7 @@ class OklchChroma(LchChroma): NAME = "oklch-chroma" - EPSILON = 0.001 + EPSILON = 0.0001 LIMIT = 0.02 DE = "ok" SPACE = "oklch" diff --git a/lib/coloraide/harmonies.py b/lib/coloraide/harmonies.py new file mode 100644 index 00000000..bd954190 --- /dev/null +++ b/lib/coloraide/harmonies.py @@ -0,0 +1,270 @@ +"""Color harmonies.""" +from . import algebra as alg +from .spaces import Cylindrical +from typing import Optional, Type, Dict, List, cast, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from .color import Color + + +class Harmony: + """Color harmony.""" + + @classmethod + def harmonize(cls, color: 'Color', space: Optional[str]) -> List['Color']: + """Get color harmonies.""" + + +class Monochromatic(Harmony): + """ + Monochromatic harmony. + + Take a given color and create both tints and shades such that we have `RANGE` total steps ranging + from black -> color -> white. Normally, we will throw away pure black, pure white, and the duplicate + target color (as we interpolate with it on both sides) leaving us with RANGE - 3 colors to extract + the target `STEPS` from. The one exception is when targeting an achromatic color, and in that case, + we only throw away the duplicate color (though if a color is close enough to white or black, white + or black may not be included simply because we cannot get a reasonable step that includes it). + + Once we have our `RANGE`, we can extract a total of `STEPS` colors with the target color at the center + (when possible). If the target color is too close to the either the minimum or maximum color step, + there may not be enough tints or shades on one side, so the result may have to draw heavier on the + side that has more plentiful tints or shades which would cause the target color to shift from the center. + + The current `RANGE` was chosen as 12 as it seems to to provide OK contrast in most cases for the monochromatic + colors. The one exception is with a target color of black or very near black which may return at least one color + with very low contrast to black. Generally, extremely dark colors do not make a good target for color harmonies, + but it should be noted that Oklch's lightness tends to the more darker side. The poor contrast may be + less with other color spaces. + """ + + DELTA_E = '2000' + RANGE = 12 + STEPS = 5 + + @classmethod + def harmonize(cls, color: 'Color', space: Optional[str]) -> List['Color']: + """Get color harmonies.""" + + if space is None: + space = color.HARMONY + + orig_space = color.space() + color0 = color.convert(space).normalize() + + if not issubclass(color0._space, Cylindrical): + raise ValueError('Color space must be cylindrical') + + # Trim off black and white unless the color is achromatic, + # But always trim duplicate target color from left side. + if not color0.is_nan('hue'): + ltrim, rtrim = slice(1, -1, None), slice(None, -1, None) + else: + ltrim, rtrim = slice(None, -1, None), slice(None, None, None) + + # Create black and white so we can generate tints and shades + # Ensure hue and alpha is masked so we don't interpolate them. + w = color.new('color(srgb 1 1 1 / none)').convert(space, in_place=True).mask(['hue', 'alpha'], in_place=True) + b = color.new('color(srgb 0 0 0 / none)').convert(space, in_place=True).mask(['hue', 'alpha'], in_place=True) + + # Calculate how many tints and shades we need to generate + db = b.delta_e(color0, method=cls.DELTA_E) + dw = w.delta_e(color0, method=cls.DELTA_E) + steps_w = int(alg.round_half_up((dw / (db + dw)) * cls.RANGE)) + steps_b = cls.RANGE - steps_w + + # Very close to black or is black, no need to interpolate from black to current color + if steps_b <= 1: + left = [] + if steps_b == 1: + left.extend(color.steps([b, color], steps=steps_b, space=space, out_space=orig_space)) + steps = min(cls.RANGE - (1 + steps_b), steps_w) + right = color.steps([color0, w], steps=steps, space=space, out_space=orig_space)[rtrim] + + # Very close to white or is white, no need to interpolate from current color to white + elif steps_w <= 1: + right = [] + if steps_w == 1: + right.extend(color.steps([color0, w], steps=steps_w, space=space, out_space=orig_space)) + steps = min(cls.RANGE - (1 + steps_w), steps_b) + right.insert(0, color.clone()) + left = color.steps([b, color], steps=steps, space=space, out_space=orig_space)[ltrim] + + else: + # Anything else in between + left = color.steps([b, color], steps=steps_b, space=space, out_space=orig_space)[ltrim] + right = color.steps([color0, w], steps=steps_w, space=space, out_space=orig_space)[rtrim] + + # Extract a subset of the results + len_l = len(left) + len_r = len(right) + l = int(cls.STEPS // 2) + r = l + (1 if cls.STEPS % 2 else 0) + if len_r < r: + return left[-cls.STEPS + len_r:] + right + elif len_l < l: + return left + right[:cls.STEPS - len_l] + return left[-l:] + right[:r] + + +class Geometric(Harmony): + """Geometrically space the colors.""" + + COUNT = 0 + + @classmethod + def harmonize(cls, color: 'Color', space: Optional[str]) -> List['Color']: + """Get color harmonies.""" + + if space is None: + space = color.HARMONY + + orig_space = color.space() + color0 = color.convert(space) + + if not issubclass(color0._space, Cylindrical): + raise ValueError('Color space must be cylindrical') + + name = color0._space.hue_name() + + degree = current = 360 / cls.COUNT + colors = [color] + for r in range(cls.COUNT - 1): + colors.append( + color0.clone().set( + name, + lambda x: cast(float, x + current) + ).convert(orig_space, in_place=True) + ) + current += degree + return colors + + +class Complementary(Geometric): + """Complementary colors.""" + + COUNT = 2 + + +class Triadic(Geometric): + """Triadic colors.""" + + COUNT = 3 + + +class TetradicSquare(Geometric): + """Tetradic (square).""" + + COUNT = 4 + + +class SplitComplementary(Harmony): + """Split Complementary colors.""" + + @classmethod + def harmonize(cls, color: 'Color', space: Optional[str]) -> List['Color']: + """Get color harmonies.""" + + if space is None: + space = color.HARMONY + + orig_space = color.space() + color0 = color.convert(space) + + if not issubclass(color0._space, Cylindrical): + raise ValueError('Color space must be cylindrical') + + name = color0._space.hue_name() + + color2 = color0.clone() + color3 = color0.clone() + color2.set(name, lambda x: cast(float, x + 210)) + color3.set(name, lambda x: cast(float, x - 210)) + return [ + color, + color2.convert(orig_space, in_place=True), + color3.convert(orig_space, in_place=True) + ] + + +class Analogous(Harmony): + """Analogous colors.""" + + @classmethod + def harmonize(cls, color: 'Color', space: Optional[str]) -> List['Color']: + """Get color harmonies.""" + + if space is None: + space = color.HARMONY + + orig_space = color.space() + color0 = color.convert(space) + + if not issubclass(color0._space, Cylindrical): + raise ValueError('Color space must be cylindrical') + + name = color0._space.hue_name() + + color2 = color0.clone() + color3 = color0.clone() + color2.set(name, lambda x: cast(float, x + 30)) + color3.set(name, lambda x: cast(float, x - 30)) + return [ + color, + color2.convert(orig_space, in_place=True), + color3.convert(orig_space, in_place=True) + ] + + +class TetradicRect(Harmony): + """Tetradic (rectangular) colors.""" + + @classmethod + def harmonize(cls, color: 'Color', space: Optional[str]) -> List['Color']: + """Get color harmonies.""" + + if space is None: + space = color.HARMONY + + orig_space = color.space() + color0 = color.convert(space) + + if not issubclass(color0._space, Cylindrical): + raise ValueError('Color space must be cylindrical') + + name = color0._space.hue_name() + + color2 = color0.clone() + color3 = color0.clone() + color4 = color0.clone() + color2.set(name, lambda x: cast(float, x + 30)) + color3.set(name, lambda x: cast(float, x + 180)) + color4.set(name, lambda x: cast(float, x + 210)) + return [ + color, + color2.convert(orig_space, in_place=True), + color3.convert(orig_space, in_place=True), + color4.convert(orig_space, in_place=True) + ] + + +SUPPORTED = { + 'complement': Complementary, + 'split': SplitComplementary, + 'triad': Triadic, + 'square': TetradicSquare, + 'rectangle': TetradicRect, + 'analogous': Analogous, + 'mono': Monochromatic +} # type: Dict[str, Type[Harmony]] + + +def harmonize(color: 'Color', name: str, space: Optional[str]) -> List['Color']: + """Get specified color harmonies.""" + + try: + h = SUPPORTED[name] + except KeyError: + raise ValueError("The color harmony '{}' cannot be found".format(name)) + + return h.harmonize(color, space) diff --git a/lib/coloraide/interpolate.py b/lib/coloraide/interpolate.py deleted file mode 100644 index 529b5cc3..00000000 --- a/lib/coloraide/interpolate.py +++ /dev/null @@ -1,511 +0,0 @@ -""" -Interpolation methods. - -Originally, the base code for `interpolate`, `mix` and `steps` was ported from the -https://colorjs.io project. Since that time, there has been significant modifications -that add additional features etc. The base logic though is attributed to the original -authors. - -In general, the logic mimics in many ways the `color-mix` function as outlined in the Level 5 -color draft (Oct 2020), but the initial approach was modeled directly off of the work done in -color.js. ---- -Original Authors: Lea Verou, Chris Lilley -License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json) -""" -import math -from abc import ABCMeta, abstractmethod -from collections import namedtuple -from . import util -from . import algebra as alg -from .types import Vector -from .spaces import Cylindrical -from .gamut.bounds import FLG_ANGLE -from typing import Optional, Callable, Sequence, Mapping, Type, Dict, List, Any, Union, cast, TYPE_CHECKING -from .types import ColorInput - -if TYPE_CHECKING: # pragma: no cover - from .color import Color - - -class Lerp: - """Linear interpolation.""" - - def __init__(self, progress: Optional[Callable[..., float]]) -> None: - """Initialize.""" - - self.progress = progress - - def __call__(self, a: float, b: float, t: float) -> float: - """Interpolate with period.""" - - return a + (b - a) * (t if self.progress is None else self.progress(t)) - - -class Piecewise(namedtuple('Piecewise', ['color', 'stop', 'progress', 'hue', 'premultiplied'])): - """Piecewise interpolation input.""" - - __slots__ = () - - def __new__( - cls, - color: ColorInput, - stop: Optional[float] = None, - progress: Optional[Callable[..., float]] = None, - hue: str = util.DEF_HUE_ADJ, - premultiplied: bool = False - ) -> 'Piecewise': - """Initialize.""" - - return super().__new__(cls, color, stop, progress, hue, premultiplied) - - -class Interpolator(metaclass=ABCMeta): - """Interpolator.""" - - @abstractmethod - def __init__(self) -> None: - """Initialize.""" - - @abstractmethod - def get_delta(self, method: Optional[str]) -> Any: - """Get the delta.""" - - @abstractmethod - def __call__(self, p: float) -> 'Color': - """Call the interpolator.""" - - def steps( - self, - steps: int = 2, - max_steps: int = 1000, - max_delta_e: float = 0, - delta_e: Optional[str] = None - ) -> List['Color']: - """Steps.""" - - return color_steps(self, steps, max_steps, max_delta_e, delta_e) - - -class InterpolateSingle(Interpolator): - """Interpolate a single range of two colors.""" - - def __init__( - self, - channels1: Vector, - channels2: Vector, - names: Sequence[str], - create: Type['Color'], - progress: Optional[Callable[..., float]], - space: str, - outspace: str, - premultiplied: bool - ) -> None: - """Initialize.""" - - self.names = names - self.channels1 = channels1 - self.channels2 = channels2 - self.create = create - self.progress = progress - self.space = space - self.outspace = outspace - self.premultiplied = premultiplied - - def get_delta(self, method: Optional[str]) -> float: - """Get the delta.""" - - return self.create(self.space, self.channels1).delta_e( - self.create(self.space, self.channels2), - method=method - ) - - def __call__(self, p: float) -> 'Color': - """Run through the coordinates and run the interpolation on them.""" - - channels = [] - for i, c1 in enumerate(self.channels1): - name = self.names[i] - c2 = self.channels2[i] - if alg.is_nan(c1) and alg.is_nan(c2): - value = alg.NaN - elif alg.is_nan(c1): - value = c2 - elif alg.is_nan(c2): - value = c1 - else: - progress = None - if isinstance(self.progress, Mapping): - progress = self.progress.get(name, self.progress.get('all')) - else: - progress = self.progress - lerp = progress if isinstance(progress, Lerp) else Lerp(progress) - value = lerp(c1, c2, p) - channels.append(value) - color = self.create(self.space, channels[:-1], channels[-1]) - if self.premultiplied: - postdivide(color) - return color.convert(self.outspace, in_place=True) if self.outspace != color.space() else color - - -class InterpolatePiecewise(Interpolator): - """Interpolate multiple ranges of colors.""" - - def __init__(self, stops: Dict[int, float], interpolators: List[InterpolateSingle]): - """Initialize.""" - - self.start = stops[0] - self.end = stops[len(stops) - 1] - self.stops = stops - self.interpolators = interpolators - - def get_delta(self, method: Optional[str]) -> Vector: - """Get the delta total.""" - - return [i.get_delta(method) for i in self.interpolators] - - def __call__(self, p: float) -> 'Color': - """Interpolate.""" - - percent = p - if percent > self.end: - # Beyond range, just interpolate the last colors - return self.interpolators[-1](1 + abs(p - self.end) if p > 1 else 1) - - elif percent < self.start: - # Beyond range, just interpolate the last colors - return self.interpolators[0](0 - abs(self.start - p) if p < 0 else 0) - - else: - last = self.start - for i, interpolator in enumerate(self.interpolators, 1): - stop = self.stops[i] - if percent <= stop: - r = stop - last - p2 = (percent - last) / r if r else 1 - return interpolator(p2) - last = stop - - # We shouldn't ever hit this, but provided for typing. - # If we do hit this, it would be a bug. - raise RuntimeError('Iterpolation could not be found for {}'.format(percent)) # pragma: no cover - - -def calc_stops(stops: Dict[int, float], count: int) -> Dict[int, float]: - """Calculate stops.""" - - # Ensure the first stop is set to zero if not explicitly set - if 0 not in stops: - stops[0] = 0 - - last = stops[0] * 100 - highest = last - empty = None - final = {} - - # Build up normalized stops - for i in range(count): - value = stops.get(i) - if value is not None: - value *= 100 - - # Found an empty hole, track the start - if value is None and empty is None: - empty = i - 1 - continue - elif value is None: - continue - - # We can't have a stop decrease in progression - if value < last: - value = last - - # Track the largest explicit value set - if value > highest: - highest = value - - # Fill in hole if one exists. - # Holes will be evenly space between the - # current and last stop. - if empty is not None: - r = i - empty - increment = (value - last) / r - for j in range(empty + 1, i): - last += increment - final[j] = last / 100 - empty = None - - # Set the stop and track it as the last - last = value - final[i] = last / 100 - - # If there is a hole at the end, fill in the hole, - # equally spacing the stops from the last to 100%. - # If the last is greater than 100%, then all will - # be equal to the last. - if empty is not None: - r = (count - 1) - empty - if highest > 100: - increment = 0 - else: - increment = (100 - last) / r - for j in range(empty + 1, count): - last += increment - final[j] = last / 100 - - return final - - -def postdivide(color: 'Color') -> None: - """Premultiply the given transparent color.""" - - if color.alpha >= 1.0: - return - - channels = color.coords() - alpha = color.alpha - coords = [] - for i, value in enumerate(channels): - - # Wrap the angle - if color._space.BOUNDS[i].flags & FLG_ANGLE: - coords.append(value) - continue - coords.append(value / alpha if alpha != 0 else value) - color._space._coords = coords - - -def premultiply(color: 'Color') -> None: - """Premultiply the given transparent color.""" - - if color.alpha >= 1.0: - return - - channels = color.coords() - alpha = color.alpha - coords = [] - for i, value in enumerate(channels): - - # Wrap the angle - if color._space.BOUNDS[i].flags & FLG_ANGLE: - coords.append(value) - continue - coords.append(value * alpha) - color._space._coords = coords - - -def adjust_hues(color1: 'Color', color2: 'Color', hue: str) -> None: - """Adjust hues.""" - - hue = hue.lower() - if hue == "specified": - return - - name = cast(Cylindrical, color1._space).hue_name() - c1 = color1.get(name) - c2 = color2.get(name) - - c1 = c1 % 360 - c2 = c2 % 360 - - if alg.is_nan(c1) or alg.is_nan(c2): - color1.set(name, c1) - color2.set(name, c2) - return - - if hue == "shorter": - if c2 - c1 > 180: - c1 += 360 - elif c2 - c1 < -180: - c2 += 360 - - elif hue == "longer": - if 0 < (c2 - c1) < 180: - c1 += 360 - elif -180 < (c2 - c1) <= 0: - c2 += 360 - - elif hue == "increasing": - if c2 < c1: - c2 += 360 - - elif hue == "decreasing": - if c1 < c2: - c1 += 360 - - else: - raise ValueError("Unknown hue adjuster '{}'".format(hue)) - - color1.set(name, c1) - color2.set(name, c2) - - -def color_steps( - interpolator: Interpolator, - steps: int = 2, - max_steps: int = 1000, - max_delta_e: float = 0, - delta_e: Optional[str] = None -) -> List['Color']: - """Color steps.""" - - if max_delta_e <= 0: - actual_steps = steps - else: - actual_steps = 0 - deltas = interpolator.get_delta(delta_e) - if not isinstance(deltas, Sequence): - deltas = [deltas] - # Make a very rough guess of required steps. - actual_steps = max(steps, sum([math.ceil(d / max_delta_e) + 1 for d in deltas])) - - 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 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.0 - for i, entry in enumerate(ret): - if i == 0: - continue - m_delta = max( - m_delta, - cast('Color', entry['color']).delta_e( - cast('Color', ret[i - 1]['color']), - method=delta_e - ) - ) - - # If we currently have delta over our limit inject more stops. - # If inserting between every color would push us over the max_steps, halt. - while m_delta > max_delta_e and (len(ret) * 2 - 1 <= max_steps): - # Inject stops while measuring again to see if it was sufficient - m_delta = 0.0 - i = 1 - while i < len(ret): - prev = ret[i - 1] - cur = ret[i] - p = (cast(float, cur['p']) + cast(float, prev['p'])) / 2 - color = interpolator(p) - m_delta = max( - m_delta, - color.delta_e(cast('Color', prev['color']), method=delta_e), - color.delta_e(cast('Color', cur['color']), method=delta_e) - ) - ret.insert(i, {'p': p, 'color': color}) - i += 2 - - return [cast('Color', i['color']) for i in ret] - - -def color_piecewise_lerp( - pw: List[Union[ColorInput, Piecewise]], - space: str, - out_space: str, - progress: Optional[Callable[..., float]], - hue: str, - premultiplied: bool -) -> InterpolatePiecewise: - """Piecewise Interpolation.""" - - # Ensure we have something we can interpolate with - count = len(pw) - if count == 1: - pw.append(pw[0]) - count += 1 - - # Construct piecewise interpolation object - stops = {} - color_map = [] - for i, x in enumerate(pw, 0): - - # Normalize all colors as Piecewise objects - if isinstance(x, Piecewise): - stops[i] = x.stop - p = x - else: - p = Piecewise(x) - - # The first is the calling color which is already a Color object - if i == 0: - current = p.color - continue - - # Ensure input provided via Piecewise object is a Color object - color = current._handle_color_input(p.color) - - # Create an entry interpolating the current color and the next color - color_map.append( - current.interpolate( - color, - space=space, - out_space=out_space, - progress=p.progress if p.progress is not None else progress, - hue=p.hue if p.hue is not None else hue, - premultiplied=p.premultiplied if p.premultiplied is not None else premultiplied - ) - ) - - # The "next" color is now the "current" color - current = color - - # Calculate stops - stops = calc_stops(stops, count) - - # Send the interpolation list along with the stop map to the Piecewise interpolator - return InterpolatePiecewise(stops, color_map) - - -def color_lerp( - color1: 'Color', - color2: ColorInput, - space: str, - out_space: str, - progress: Optional[Callable[..., float]], - hue: str, - premultiplied: bool -) -> InterpolateSingle: - """Color interpolation.""" - - # Convert to the color space and ensure the color fits inside - fit = not color1.CS_MAP[space].EXTENDED_RANGE - color1 = color1.convert(space, fit=fit) - color2 = color1._handle_color_input(color2).convert(space, fit=fit) - - # Adjust hues if we have two valid hues - if isinstance(color1._space, Cylindrical): - adjust_hues(color1, color2, hue) - - if premultiplied: - premultiply(color1) - premultiply(color2) - - channels1 = color1.coords() - channels2 = color2.coords() - - # Include alpha - channels1.append(color1.alpha) - channels2.append(color2.alpha) - - return InterpolateSingle( - names=color1._space.CHANNEL_NAMES + ('alpha',), - channels1=channels1, - channels2=channels2, - create=type(color1), - progress=progress, - space=space, - outspace=out_space, - premultiplied=premultiplied - ) diff --git a/lib/coloraide/interpolate/__init__.py b/lib/coloraide/interpolate/__init__.py new file mode 100644 index 00000000..0d9c3836 --- /dev/null +++ b/lib/coloraide/interpolate/__init__.py @@ -0,0 +1,36 @@ +""" +Interpolation methods. + +Originally, the base code for `interpolate`, `mix` and `steps` was ported from the +https://colorjs.io project. Since that time, there has been significant modifications +that add additional features etc. The base logic though is attributed to the original +authors. + +In general, the logic mimics in many ways the `color-mix` function as outlined in the Level 5 +color draft (Oct 2020), but the initial approach was modeled directly off of the work done in +color.js. +--- +Original Authors: Lea Verou, Chris Lilley +License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json) +""" +from .bezier import color_bezier_lerp +from .piecewise import color_piecewise_lerp +from .common import Interpolator, hint, stop # noqa: F401 +from typing import Callable, Dict + +__all__ = ('stop', 'hint', 'get_interpolator') + + +SUPPORTED = { + "linear": color_piecewise_lerp, + "bezier": color_bezier_lerp +} # type: Dict[str, Callable[..., Interpolator]] + + +def get_interpolator(interpolator: str) -> Callable[..., Interpolator]: + """Get desired blend mode.""" + + try: + return SUPPORTED[interpolator] + except KeyError: + raise ValueError("'{}' is not a recognized interpolator".format(interpolator)) diff --git a/lib/coloraide/interpolate/bezier.py b/lib/coloraide/interpolate/bezier.py new file mode 100644 index 00000000..a19b4bd1 --- /dev/null +++ b/lib/coloraide/interpolate/bezier.py @@ -0,0 +1,248 @@ +"""Bezier interpolation.""" +from .. import algebra as alg +from ..spaces import Cylindrical +from ..types import Vector, ColorInput +from typing import Optional, Callable, Sequence, Mapping, Type, Dict, List, Union, cast, Any, TYPE_CHECKING +from .common import stop, Interpolator, calc_stops, process_mapping, premultiply, postdivide + +if TYPE_CHECKING: # pragma: no cover + from ..color import Color + + +def binomial_row(n: int) -> List[int]: + """ + Binomial row. + + Return row in Pascal's triangle. + """ + + row = [1, 1] + for i in range(n - 1): + r = [1] + x = 0 + for x in range(1, len(row)): + r.append(row[x] + row[x - 1]) + r.append(row[x]) + row = r + return row + + +class InterpolateBezier(Interpolator): + """Interpolate Bezier.""" + + def __init__( + self, + coordinates: List[Vector], + names: Sequence[str], + create: Type['Color'], + easings: List[Optional[Callable[..., float]]], + stops: Dict[int, float], + space: str, + out_space: str, + progress: Optional[Union[Callable[..., float], Mapping[str, Callable[..., float]]]], + premultiplied: bool + ) -> None: + """Initialize.""" + + self.start = stops[0] + self.end = stops[len(stops) - 1] + self.stops = stops + self.easings = easings + self.coordinates = coordinates + self.length = len(self.coordinates) + self.names = names + self.create = create + self.progress = progress + self.space = space + self.out_space = out_space + self.premultiplied = premultiplied + + def handle_undefined(self, coords: Vector) -> Vector: + """Handle null values.""" + + backfill = None + for x in range(1, len(coords)): + a = coords[x - 1] + b = coords[x] + if alg.is_nan(a) and not alg.is_nan(b): + coords[x - 1] = b + elif alg.is_nan(b) and not alg.is_nan(a): + coords[x] = a + elif alg.is_nan(a) and alg.is_nan(b): + # Multiple undefined values, mark the start + backfill = x - 1 + continue + + # Replace all undefined values that occurred prior to + # finding the current defined value + if backfill is not None: + coords[backfill:x - 1] = [b] * (x - 1 - backfill) + backfill = None + + return coords + + def interpolate( + self, + easing: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], + p2: float, + first: float, + last: float + ) -> 'Color': + """Interpolate.""" + + n = self.length - 1 + row = binomial_row(n) + channels = [] + for i, coords in enumerate(zip(*self.coordinates)): + name = self.names[i] + progress = None + if isinstance(easing, Mapping): + progress = easing.get(name) + if progress is None: + progress = easing.get('all') + else: + progress = easing + + # Apply easing and scale properly between the colors + t = alg.clamp(p2 if progress is None else progress(p2), 0.0, 1.0) + t = t * (last - first) + first + + # Find new points using a bezier curve + x = 1 - t + s = 0.0 + for j, c in enumerate(self.handle_undefined(list(coords)), 0): + s += row[j] * (x ** (n - j)) * (t ** j) * c + + channels.append(s) + color = self.create(self.space, channels[:-1], channels[-1]) + if self.premultiplied: + postdivide(color) + return color.convert(self.out_space, in_place=True) if self.out_space != color.space() else color + + def __call__(self, p: float) -> 'Color': + """Interpolate.""" + + percent = alg.clamp(p, 0.0, 1.0) + if percent > self.end: + percent = self.end + elif percent < self.start: + percent = self.start + last = self.start + for i in range(1, self.length): + s = self.stops[i] + if percent <= s: + r = s - last + p2 = (percent - last) / r if r else 1 + easing = self.easings[i - 1] # type: Any + if easing is None: + easing = self.progress + piece = 1 / (self.length - 1) + return self.interpolate(easing, p2, (i - 1) * piece, i * piece) + last = s + + # We shouldn't ever hit this, but provided for typing. + # If we do hit this, it would be a bug. + raise RuntimeError('Iterpolation could not be found for {}'.format(percent)) # pragma: no cover + + +def normalize_color(color: 'Color', space: str, premultiplied: bool) -> None: + """Normalize color.""" + + # Adjust to color to space and ensure it fits + if not color.CS_MAP[space].EXTENDED_RANGE: + if not color.in_gamut(): + color.fit() + + # Premultiply + if premultiplied: + premultiply(color) + + # Normalize hue + if issubclass(color._space, Cylindrical): + name = cast(Type[Cylindrical], color._space).hue_name() + color.set(name, lambda h: cast(float, h % 360)) + + +def color_bezier_lerp( + create: Type['Color'], + colors: List[ColorInput], + space: str, + out_space: str, + progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], + premultiplied: bool, + **kwargs: Any +) -> InterpolateBezier: + """Bezier interpolation.""" + + # Construct piecewise interpolation object + stops = {} # type: Any + + if space is None: + space = create.INTERPOLATE + + if isinstance(colors[0], stop): + current = create(colors[0].color) + stops[0] = colors[0].stop + elif not callable(colors[0]): + current = create(colors[0]) + stops[0] = None + else: + raise ValueError('Cannot have an easing function as the first item in an interpolation list') + + if out_space is None: + out_space = current.space() + + current.convert(space, in_place=True) + normalize_color(current, space, premultiplied) + + easing = None # type: Any + easings = [] # type: Any + coords = [current[:]] + + i = 0 + for x in colors[1:]: + + # Normalize all colors as Piecewise objects + if isinstance(x, stop): + i += 1 + stops[i] = x.stop + color = current._handle_color_input(x.color) + elif callable(x): + easing = x + continue + else: + i += 1 + color = current._handle_color_input(x) + stops[i] = None + + # Adjust to color to space and ensure it fits + color = color.convert(space) + normalize_color(color, space, premultiplied) + + # Create an entry interpolating the current color and the next color + coords.append(color[:]) + easings.append(easing if easing is not None else progress) + + # The "next" color is now the "current" color + easing = None + current = color + + i += 1 + if i < 2: + raise ValueError('Need at least two colors to interpolate') + + # Calculate stops + stops = calc_stops(stops, i) + + # Send the interpolation list along with the stop map to the Piecewise interpolator + return InterpolateBezier( + coords, + [str(c) for c in current._space.get_all_channels()], + create, + easings, + stops, + space, + out_space, + process_mapping(progress, current._space.CHANNEL_ALIASES), + premultiplied + ) diff --git a/lib/coloraide/interpolate/common.py b/lib/coloraide/interpolate/common.py new file mode 100644 index 00000000..478246e7 --- /dev/null +++ b/lib/coloraide/interpolate/common.py @@ -0,0 +1,236 @@ +"""Common tools.""" +import math +import functools +from abc import ABCMeta, abstractmethod +from .. import algebra as alg +from ..channels import FLG_ANGLE +from ..types import ColorInput +from typing import Optional, Callable, Mapping, Dict, List, Union, cast, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from ..color import Color + + +def midpoint(t: float, h: float = 0.5) -> float: + """Midpoint easing function.""" + + return 0.0 if h <= 0 or h >= 1 else math.pow(t, math.log(0.5) / math.log(h)) + + +def hint(mid: float) -> Callable[..., float]: + """A generate a midpoint easing function.""" + + return functools.partial(midpoint, h=mid) + + +class stop: + """Color stop.""" + + __slots__ = ('color', 'stop') + + def __init__(self, color: ColorInput, value: float) -> None: + """Color stops.""" + + self.color = color + self.stop = value + + +class Interpolator(metaclass=ABCMeta): + """Interpolator.""" + + @abstractmethod + def __init__(self) -> None: + """Initialize.""" + + @abstractmethod + def __call__(self, p: float) -> 'Color': + """Call the interpolator.""" + + def steps( + self, + steps: int = 2, + max_steps: int = 1000, + max_delta_e: float = 0, + delta_e: Optional[str] = None + ) -> List['Color']: + """Steps.""" + + return color_steps(self, steps, max_steps, max_delta_e, delta_e) + + +def calc_stops(stops: Dict[int, float], count: int) -> Dict[int, float]: + """Calculate stops.""" + + # Ensure the first stop is set to zero if not explicitly set + if 0 not in stops or stops[0] is None: + stops[0] = 0 + + last = stops[0] * 100 + highest = last + empty = None + final = {} + + # Build up normalized stops + for i in range(count): + value = stops.get(i) + if value is not None: + value *= 100 + + # Found an empty hole, track the start + if value is None and empty is None: + empty = i - 1 + continue + elif value is None: + continue + + # We can't have a stop decrease in progression + if value < last: + value = last + + # Track the largest explicit value set + if value > highest: + highest = value + + # Fill in hole if one exists. + # Holes will be evenly space between the + # current and last stop. + if empty is not None: + r = i - empty + increment = (value - last) / r + for j in range(empty + 1, i): + last += increment + final[j] = last / 100 + empty = None + + # Set the stop and track it as the last + last = value + final[i] = last / 100 + + # If there is a hole at the end, fill in the hole, + # equally spacing the stops from the last to 100%. + # If the last is greater than 100%, then all will + # be equal to the last. + if empty is not None: + r = (count - 1) - empty + if highest > 100: + increment = 0 + else: + increment = (100 - last) / r + for j in range(empty + 1, count): + last += increment + final[j] = last / 100 + + return final + + +def process_mapping( + progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], + aliases: Mapping[str, str] +) -> Optional[Union[Callable[..., float], Mapping[str, Callable[..., float]]]]: + """Process a mapping, such that it is not using aliases.""" + + if not isinstance(progress, Mapping): + return progress + return {aliases.get(k, k): v for k, v in progress.items()} + + +def postdivide(color: 'Color') -> None: + """Premultiply the given transparent color.""" + + alpha = color[-1] + + if alg.is_nan(alpha) or alpha in (0.0, 1.0): + return + + channels = color._space.CHANNELS + for i, value in enumerate(color[:-1]): + + # Wrap the angle + if channels[i].flags & FLG_ANGLE: + continue + color[i] = value / alpha + + +def premultiply(color: 'Color') -> None: + """Premultiply the given transparent color.""" + + alpha = color[-1] + + if alg.is_nan(alpha) or alpha == 1.0: + return + + channels = color._space.CHANNELS + for i, value in enumerate(color[:-1]): + + # Wrap the angle + if channels[i].flags & FLG_ANGLE: + continue + color[i] = value * alpha + + +def color_steps( + interpolator: Interpolator, + steps: int = 2, + max_steps: int = 1000, + max_delta_e: float = 0, + delta_e: Optional[str] = None +) -> List['Color']: + """Color steps.""" + + actual_steps = steps + + # Allocate at least two steps if we are doing a maximum delta E, + if max_delta_e != 0 and actual_steps < 2: + actual_steps = 2 + + # Make sure we don't start out allocating too many colors + 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)}] + elif actual_steps > 1: + 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 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.0 + for i in range(1, len(ret)): + m_delta = max( + m_delta, + cast('Color', ret[i - 1]['color']).delta_e( + cast('Color', ret[i]['color']), + method=delta_e + ) + ) + + # If we currently have delta over our limit inject more stops. + # If inserting between every color would push us over the max_steps, halt. + total = len(ret) + while m_delta > max_delta_e and (total * 2 - 1 <= max_steps): + # Inject stops while measuring again to see if it was sufficient + m_delta = 0.0 + i = 1 + index = 1 + while index < total: + prev = ret[index - 1] + cur = ret[index] + p = (cast(float, cur['p']) + cast(float, prev['p'])) / 2 + color = interpolator(p) + m_delta = max( + m_delta, + color.delta_e(cast('Color', prev['color']), method=delta_e), + color.delta_e(cast('Color', cur['color']), method=delta_e) + ) + ret.insert(index, {'p': p, 'color': color}) + total += 1 + index += 2 + + return [cast('Color', i['color']) for i in ret] diff --git a/lib/coloraide/interpolate/piecewise.py b/lib/coloraide/interpolate/piecewise.py new file mode 100644 index 00000000..04827ed6 --- /dev/null +++ b/lib/coloraide/interpolate/piecewise.py @@ -0,0 +1,246 @@ +"""Piecewise linear interpolation.""" +from .. import algebra as alg +from ..spaces import Cylindrical +from ..types import Vector, ColorInput +from typing import Optional, Callable, Sequence, Mapping, Type, Dict, List, Union, cast, Tuple, Any, TYPE_CHECKING +from .common import stop, Interpolator, calc_stops, process_mapping, premultiply, postdivide + +if TYPE_CHECKING: # pragma: no cover + from ..color import Color + + +def adjust_hues(color1: 'Color', color2: 'Color', hue: str) -> None: + """Adjust hues.""" + + if hue == "specified": + return + + name = cast(Type[Cylindrical], color1._space).hue_name() + c1 = color1.get(name) + c2 = color2.get(name) + + c1 = c1 % 360 + c2 = c2 % 360 + + if alg.is_nan(c1) or alg.is_nan(c2): + color1.set(name, c1) + color2.set(name, c2) + return + + if hue == "shorter": + if c2 - c1 > 180: + c1 += 360 + elif c2 - c1 < -180: + c2 += 360 + + elif hue == "longer": + if 0 < (c2 - c1) < 180: + c1 += 360 + elif -180 < (c2 - c1) <= 0: + c2 += 360 + + elif hue == "increasing": + if c2 < c1: + c2 += 360 + + elif hue == "decreasing": + if c1 < c2: + c1 += 360 + + else: + raise ValueError("Unknown hue adjuster '{}'".format(hue)) + + color1.set(name, c1) + color2.set(name, c2) + + +class InterpolatePiecewise(Interpolator): + """Interpolate multiple ranges of colors.""" + + def __init__( + self, + color_map: List[Tuple[Vector, Vector]], + names: Sequence[str], + create: Type['Color'], + easings: List[Optional[Callable[..., float]]], + stops: Dict[int, float], + space: str, + out_space: str, + progress: Optional[Union[Callable[..., float], Mapping[str, Callable[..., float]]]], + premultiplied: bool + ): + """Initialize.""" + + self.start = stops[0] + self.end = stops[len(stops) - 1] + self.stops = stops + self.color_map = color_map + self.names = names + self.create = create + self.easings = easings + self.space = space + self.out_space = out_space + self.progress = progress + self.premultiplied = premultiplied + + def interpolate( + self, + colors: Tuple[Vector, Vector], + easing: Optional[Union[Callable[..., float], Mapping[str, Callable[..., float]]]], + p: float + ) -> 'Color': + """Interpolate.""" + + channels = [] + for i, values in enumerate(zip(*colors)): + c1, c2 = values + name = self.names[i] + if alg.is_nan(c1) and alg.is_nan(c2): + value = alg.NaN + elif alg.is_nan(c1): + value = c2 + elif alg.is_nan(c2): + value = c1 + else: + progress = None + if isinstance(easing, Mapping): + progress = easing.get(name) + if progress is None: + progress = easing.get('all') + else: + progress = easing + t = alg.clamp(progress(p), 0.0, 1.0) if progress is not None else p + value = alg.lerp(c1, c2, t) + channels.append(value) + color = self.create(self.space, channels[:-1], channels[-1]) + if self.premultiplied: + postdivide(color) + return color.convert(self.out_space, in_place=True) if self.out_space != color.space() else color + + def __call__(self, p: float) -> 'Color': + """Interpolate.""" + + percent = alg.clamp(p, 0.0, 1.0) + if percent > self.end: + percent = self.end + elif percent < self.start: + percent = self.start + last = self.start + for i, colors in enumerate(self.color_map, 1): + s = self.stops[i] + if percent <= s: + r = s - last + p2 = (percent - last) / r if r else 1 + easing = self.easings[i - 1] # type: Any + if easing is None: + easing = self.progress + return self.interpolate(colors, easing, p2) + last = s + + # We shouldn't ever hit this, but provided for typing. + # If we do hit this, it would be a bug. + raise RuntimeError('Iterpolation could not be found for {}'.format(percent)) # pragma: no cover + + +def normalize_color(color: 'Color', space: str, premultiplied: bool) -> None: + """Normalize the color.""" + + # Adjust to color to space and ensure it fits + if not color.CS_MAP[space].EXTENDED_RANGE: + if not color.in_gamut(): + color.fit() + + # Premultiply + if premultiplied: + premultiply(color) + + +def color_piecewise_lerp( + create: Type['Color'], + colors: List[Union[ColorInput, stop, Callable[..., float]]], + space: str, + out_space: str, + progress: Optional[Union[Mapping[str, Callable[..., float]], Callable[..., float]]], + hue: str, + premultiplied: bool, + **kwargs: Any +) -> InterpolatePiecewise: + """Piecewise Interpolation.""" + + # Construct piecewise interpolation object + stops = {} # type: Any + color_map = [] + + if space is None: + space = create.INTERPOLATE + + if isinstance(colors[0], stop): + current = create(colors[0].color) + stops[0] = colors[0].stop + elif not callable(colors[0]): + current = create(colors[0]) + stops[0] = None + else: + raise ValueError('Cannot have an easing function as the first item in an interpolation list') + + if out_space is None: + out_space = current.space() + + current.convert(space, in_place=True) + normalize_color(current, space, premultiplied) + + easing = None # type: Any + easings = [] # type: Any + + i = 0 + for x in colors[1:]: + + # Normalize all colors as Piecewise objects + if isinstance(x, stop): + i += 1 + stops[i] = x.stop + color = current._handle_color_input(x.color) + elif callable(x): + easing = x + continue + else: + i += 1 + color = current._handle_color_input(x) + stops[i] = None + + # Adjust to color to space and ensure it fits + color = color.convert(space) + normalize_color(color, space, premultiplied) + + # Adjust hues if we have two valid hues + color2 = color.clone() + if issubclass(current._space, Cylindrical): + adjust_hues(current, color2, hue) + + # Create an entry interpolating the current color and the next color + color_map.append((current[:], color2[:])) + easings.append(easing if easing is not None else progress) + + # The "next" color is now the "current" color + easing = None + current = color + + i += 1 + if i < 2: + raise ValueError('Need at least two colors to interpolate') + + # Calculate stops + stops = calc_stops(stops, i) + + # Send the interpolation list along with the stop map to the Piecewise interpolator + return InterpolatePiecewise( + color_map, + [str(c) for c in current._space.get_all_channels()], + create, + easings, + stops, + space, + out_space, + process_mapping(progress, current._space.CHANNEL_ALIASES), + premultiplied + ) diff --git a/lib/coloraide/spaces/__init__.py b/lib/coloraide/spaces/__init__.py index 0502925e..fb77a027 100644 --- a/lib/coloraide/spaces/__init__.py +++ b/lib/coloraide/spaces/__init__.py @@ -1,25 +1,18 @@ """Color base.""" from abc import ABCMeta, abstractmethod -from .. import util from .. import cat from ..css import parse -from ..gamut import bounds +from ..channels import Channel from ..css import serialize from .. import algebra as alg -from ..types import VectorLike, Vector -from typing import Tuple, Dict, Optional, Union, Sequence, Any, List, cast, Type, TYPE_CHECKING +from ..types import VectorLike, Vector, Plugin +from typing import Tuple, Dict, Optional, Union, Any, List, cast, Type, Iterator, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from ..color import Color # TODO: Remove for before 1.0. # Here only to prevent breakage. -FLG_ANGLE = bounds.FLG_ANGLE -FLG_OPT_PERCENT = bounds.FLG_OPT_PERCENT -FLG_PERCENT = bounds.FLG_PERCENT -Bounds = bounds.Bounds -GamutBound = bounds.GamutBound -GamutUnbound = bounds.GamutUnbound RE_DEFAULT_MATCH = parse.RE_DEFAULT_MATCH WHITES = cat.WHITES @@ -37,7 +30,7 @@ def hue_name(cls) -> str: def hue_index(cls) -> int: # pragma: no cover """Get hue index.""" - return cast(Type['Space'], cls).CHANNEL_NAMES.index(cls.hue_name()) + return cast(Type['Space'], cls).get_channel_index(cls.hue_name()) class Labish: @@ -47,14 +40,14 @@ class Labish: def labish_names(cls) -> Tuple[str, ...]: """Return Lab-ish names in the order L a b.""" - return cast(Type['Space'], cls).CHANNEL_NAMES + return cast(Type['Space'], cls).CHANNELS @classmethod def labish_indexes(cls) -> List[int]: # pragma: no cover """Return the index of the Lab-ish channels.""" names = cls.labish_names() - return [cast(Type['Space'], cls).CHANNEL_NAMES.index(name) for name in names] + return [cast(Type['Space'], cls).get_channel_index(name) for name in names] class Lchish(Cylindrical): @@ -64,17 +57,20 @@ class Lchish(Cylindrical): def lchish_names(cls) -> Tuple[str, ...]: # pragma: no cover """Return Lch-ish names in the order L c h.""" - return cast(Type['Space'], cls).CHANNEL_NAMES + return cast(Type['Space'], cls).CHANNELS @classmethod def lchish_indexes(cls) -> List[int]: # pragma: no cover """Return the index of the Lab-ish channels.""" names = cls.lchish_names() - return [cast(Type['Space'], cls).CHANNEL_NAMES.index(name) for name in names] + return [cast(Type['Space'], cls).get_channel_index(name) for name in names] -class BaseSpace(ABCMeta): +alpha_channel = Channel('alpha', 0.0, 1.0, bound=True, limit=(0.0, 1.0)) + + +class SpaceMeta(ABCMeta): """Ensure on subclass that the subclass has new instances of mappings.""" def __init__(cls, name: str, bases: Tuple[object, ...], clsdict: Dict[str, Any]) -> None: @@ -84,9 +80,7 @@ def __init__(cls, name: str, bases: Tuple[object, ...], clsdict: Dict[str, Any]) cls.CHANNEL_ALIASES = cls.CHANNEL_ALIASES.copy() # type: Dict[str, str] -class Space( - metaclass=BaseSpace -): +class Space(Plugin, metaclass=SpaceMeta): """Base color space object.""" BASE = "" # type: str @@ -95,7 +89,7 @@ class Space( # Serialized name SERIALIZE = tuple() # type: Tuple[str, ...] # Channel names - CHANNEL_NAMES = tuple() # type: Tuple[str, ...] + CHANNELS = tuple() # type: Tuple[Channel, ...] # Channel aliases CHANNEL_ALIASES = {} # type: Dict[str, str] # Enable or disable default color format parsing and serialization. @@ -115,51 +109,29 @@ class Space( # ranges, then the colors will not be gamut mapped even if their gamut is larger than the target interpolation # space. EXTENDED_RANGE = False - # Bounds of channels. Range could be suggested or absolute as not all spaces have definitive ranges. - BOUNDS = tuple() # type: Tuple[Bounds, ...] # White point WHITE = (0.0, 0.0) - def __init__(self, color: Union['Space', VectorLike], alpha: Optional[float] = None) -> None: - """Initialize.""" - - num_channels = len(self.CHANNEL_NAMES) - self._alpha = alg.NaN # type: float - self._coords = [alg.NaN] * num_channels - self._chan_names = set(self.CHANNEL_NAMES) - self._chan_names.add('alpha') - - if isinstance(color, Space): - self._coords = color.coords() - self.alpha = color.alpha - elif isinstance(color, Sequence): - if len(color) != num_channels: # pragma: no cover - # Only likely to happen with direct usage internally. - raise ValueError( - "{} accepts a list of {} channels".format(self.NAME, num_channels) - ) - for name, value in zip(self.CHANNEL_NAMES, color): - setattr(self, name, float(value)) - self.alpha = 1.0 if alpha is None else alpha - else: # pragma: no cover - # Only likely to happen with direct usage internally. - raise TypeError("Unexpected type '{}' received".format(type(color))) - - def __repr__(self) -> str: - """Representation.""" - - return 'color({} {} / {})'.format( - self._serialize()[0], - ' '.join([util.fmt_float(coord, util.DEF_PREC) for coord in self.coords()]), - util.fmt_float(alg.no_nan(self.alpha), util.DEF_PREC) - ) + @classmethod + def get_channel_index(cls, name: str) -> int: + """Get channel index.""" - __str__ = __repr__ + if name == 'alpha': + return len(cls.CHANNELS) + return cls.CHANNELS.index(cls.CHANNEL_ALIASES.get(name, name)) - def coords(self) -> Vector: - """Coordinates.""" + @classmethod + def get_channel(cls, index: int) -> Channel: + """Get channel index.""" - return self._coords[:] + return (cls.CHANNELS + (alpha_channel,))[index] + + @classmethod + def get_all_channels(cls) -> Iterator[Channel]: + """Get all channels.""" + + yield from cls.CHANNELS + yield alpha_channel @classmethod def _serialize(cls) -> Tuple[str, ...]: @@ -173,34 +145,6 @@ def white(cls) -> VectorLike: return cls.WHITE - @property - def alpha(self) -> float: - """Alpha channel.""" - - return self._alpha - - @alpha.setter - def alpha(self, value: float) -> None: - """Adjust alpha.""" - - self._alpha = alg.clamp(value, 0.0, 1.0) - - def set(self, name: str, value: float) -> None: # noqa: A003 - """Set the given channel.""" - - name = self.CHANNEL_ALIASES.get(name, name) - if name not in self._chan_names: - raise AttributeError("'{}' is an invalid channel name".format(name)) - setattr(self, name, float(value)) - - def get(self, name: str) -> float: - """Get the given channel's value.""" - - name = self.CHANNEL_ALIASES.get(name, name) - if name not in self._chan_names: - raise AttributeError("'{}' is an invalid channel name".format(name)) - return cast(float, getattr(self, name)) - @classmethod @abstractmethod def to_base(cls, coords: Vector) -> Vector: # pragma: no cover @@ -211,8 +155,9 @@ def to_base(cls, coords: Vector) -> Vector: # pragma: no cover def from_base(cls, coords: Vector) -> Vector: # pragma: no cover """From base color.""" + @classmethod def to_string( - self, + cls, parent: 'Color', *, alpha: Optional[bool] = None, @@ -233,10 +178,10 @@ def to_string( ) @classmethod - def null_adjust(cls, coords: Vector, alpha: float) -> Tuple[Vector, float]: + def normalize(cls, coords: Vector) -> Vector: """Process coordinates and adjust any channels to null/NaN if required.""" - return alg.no_nans(coords), alg.no_nan(alpha) + return alg.no_nans(coords) @classmethod def match( diff --git a/lib/coloraide/spaces/a98_rgb.py b/lib/coloraide/spaces/a98_rgb.py index 60a5a8ec..a156a849 100644 --- a/lib/coloraide/spaces/a98_rgb.py +++ b/lib/coloraide/spaces/a98_rgb.py @@ -3,38 +3,6 @@ from .srgb import SRGB from .. import algebra as alg from ..types import Vector -from typing import cast - -RGB_TO_XYZ = [ - [0.5766690429101305, 0.1855582379065463, 0.1882286462349947], - [0.29734497525053605, 0.6273635662554661, 0.07529145849399788], - [0.02703136138641234, 0.07068885253582723, 0.9913375368376388] -] - -XYZ_TO_RGB = [ - [2.0415879038107465, -0.5650069742788596, -0.34473135077832956], - [-0.9692436362808795, 1.8759675015077202, 0.04155505740717558], - [0.013444280632031142, -0.11836239223101837, 1.0151749943912054] -] - - -def lin_a98rgb_to_xyz(rgb: Vector) -> Vector: - """ - Convert an array of linear-light a98-rgb values to CIE XYZ using D50.D65. - - (so no chromatic adaptation needed afterwards) - http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html - which has greater numerical precision than section 4.3.5.3 of - https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf - """ - - return cast(Vector, alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1)) - - -def xyz_to_lin_a98rgb(xyz: Vector) -> Vector: - """Convert XYZ to linear-light a98-rgb.""" - - return cast(Vector, alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1)) def lin_a98rgb(rgb: Vector) -> Vector: @@ -52,7 +20,7 @@ def gam_a98rgb(rgb: Vector) -> Vector: class A98RGB(SRGB): """A98 RGB class.""" - BASE = "xyz-d65" + BASE = "a98-rgb-linear" NAME = "a98-rgb" WHITE = WHITES['2deg']['D65'] @@ -60,10 +28,10 @@ class A98RGB(SRGB): def to_base(cls, coords: Vector) -> Vector: """To XYZ from A98 RGB.""" - return lin_a98rgb_to_xyz(lin_a98rgb(coords)) + return lin_a98rgb(coords) @classmethod def from_base(cls, coords: Vector) -> Vector: """From XYZ to A98 RGB.""" - return gam_a98rgb(xyz_to_lin_a98rgb(coords)) + return gam_a98rgb(coords) diff --git a/lib/coloraide/spaces/a98_rgb_linear.py b/lib/coloraide/spaces/a98_rgb_linear.py new file mode 100644 index 00000000..77f37eb7 --- /dev/null +++ b/lib/coloraide/spaces/a98_rgb_linear.py @@ -0,0 +1,57 @@ +"""Linear A98 RGB color class.""" +from ..cat import WHITES +from .srgb import SRGB +from .. import algebra as alg +from ..types import Vector + +RGB_TO_XYZ = [ + [0.5766690429101305, 0.1855582379065463, 0.1882286462349947], + [0.29734497525053605, 0.6273635662554661, 0.07529145849399788], + [0.02703136138641234, 0.07068885253582723, 0.9913375368376388] +] + +XYZ_TO_RGB = [ + [2.0415879038107465, -0.5650069742788596, -0.34473135077832956], + [-0.9692436362808795, 1.8759675015077202, 0.04155505740717558], + [0.013444280632031142, -0.11836239223101837, 1.0151749943912054] +] + + +def lin_a98rgb_to_xyz(rgb: Vector) -> Vector: + """ + Convert an array of linear-light a98-rgb values to CIE XYZ using D50.D65. + + (so no chromatic adaptation needed afterwards) + http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + which has greater numerical precision than section 4.3.5.3 of + https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf + """ + + return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + + +def xyz_to_lin_a98rgb(xyz: Vector) -> Vector: + """Convert XYZ to linear-light a98-rgb.""" + + return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + + +class A98RGBLinear(SRGB): + """Linear A98 RGB class.""" + + BASE = "xyz-d65" + NAME = "a98-rgb-linear" + SERIALIZE = ('--a98-rgb-linear',) + WHITE = WHITES['2deg']['D65'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To XYZ from A98 RGB.""" + + return lin_a98rgb_to_xyz(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From XYZ to A98 RGB.""" + + return xyz_to_lin_a98rgb(coords) diff --git a/lib/coloraide/spaces/cmy.py b/lib/coloraide/spaces/cmy.py new file mode 100644 index 00000000..1776ee0c --- /dev/null +++ b/lib/coloraide/spaces/cmy.py @@ -0,0 +1,49 @@ +"""Uncalibrated, naive CMY color space.""" +from ..spaces import Space +from ..channels import Channel +from ..cat import WHITES +from ..types import Vector +from typing import Tuple + + +def srgb_to_cmy(rgb: Vector) -> Vector: + """Convert sRGB to CMY.""" + + return [1 - c for c in rgb] + + +def cmy_to_srgb(cmy: Vector) -> Vector: + """Convert CMY to sRGB.""" + + return [1 - c for c in cmy] + + +class CMY(Space): + """The CMY color class.""" + + BASE = "srgb" + NAME = "cmy" + SERIALIZE = ("--cmy",) # type: Tuple[str, ...] + CHANNELS = ( + Channel("c", 0.0, 1.0, bound=True), + Channel("m", 0.0, 1.0, bound=True), + Channel("y", 0.0, 1.0, bound=True) + ) + CHANNEL_ALIASES = { + "cyan": 'c', + "magenta": 'm', + "yellow": 'y' + } + WHITE = WHITES['2deg']['D65'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To sRGB.""" + + return cmy_to_srgb(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From sRGB.""" + + return srgb_to_cmy(coords) diff --git a/lib/coloraide/spaces/cmyk.py b/lib/coloraide/spaces/cmyk.py new file mode 100644 index 00000000..38c43da0 --- /dev/null +++ b/lib/coloraide/spaces/cmyk.py @@ -0,0 +1,68 @@ +""" +Uncalibrated, naive CMYK color space. + +https://www.w3.org/TR/css-color-5/#cmyk-rgb +""" +from ..spaces import Space +from ..channels import Channel +from ..cat import WHITES +from ..types import Vector +from typing import Tuple + + +def srgb_to_cmyk(rgb: Vector) -> Vector: + """Convert sRGB to CMYK.""" + + k = 1.0 - max(rgb) + c = m = y = 0.0 + if k != 1: + r, g, b = rgb + c = (1.0 - r - k) / (1.0 - k) + m = (1.0 - g - k) / (1.0 - k) + y = (1.0 - b - k) / (1.0 - k) + + return [c, m, y, k] + + +def cmyk_to_srgb(cmyk: Vector) -> Vector: + """Convert CMYK to sRGB.""" + + c, m, y, k = cmyk + return [ + 1.0 - min(1.0, c * (1.0 - k) + k), + 1.0 - min(1.0, m * (1.0 - k) + k), + 1.0 - min(1.0, y * (1.0 - k) + k) + ] + + +class CMYK(Space): + """The CMYK color class.""" + + BASE = "srgb" + NAME = "cmyk" + SERIALIZE = ("--cmyk",) # type: Tuple[str, ...] + CHANNELS = ( + Channel("c", 0.0, 1.0, bound=True), + Channel("m", 0.0, 1.0, bound=True), + Channel("y", 0.0, 1.0, bound=True), + Channel("k", 0.0, 1.0, bound=True) + ) + CHANNEL_ALIASES = { + "cyan": 'c', + "magenta": 'm', + "yellow": 'y', + "black": 'k' + } + WHITE = WHITES['2deg']['D65'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To sRGB.""" + + return cmyk_to_srgb(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From sRGB.""" + + return srgb_to_cmyk(coords) diff --git a/lib/coloraide/spaces/din99o.py b/lib/coloraide/spaces/din99o.py index 965e3812..7ca49e49 100644 --- a/lib/coloraide/spaces/din99o.py +++ b/lib/coloraide/spaces/din99o.py @@ -7,6 +7,7 @@ from .lab import Lab import math from ..types import Vector +from ..channels import Channel, FLG_MIRROR_PERCENT KE = 1 KCH = 1 @@ -96,6 +97,11 @@ class Din99o(Lab): NAME = "din99o" SERIALIZE = ("--din99o",) WHITE = WHITES['2deg']['D65'] + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("a", -55.0, 55.0, flags=FLG_MIRROR_PERCENT), + Channel("b", -55.0, 55.0, flags=FLG_MIRROR_PERCENT) + ) @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/display_p3.py b/lib/coloraide/spaces/display_p3.py index 712c82e0..4970a569 100644 --- a/lib/coloraide/spaces/display_p3.py +++ b/lib/coloraide/spaces/display_p3.py @@ -1,56 +1,13 @@ """Display-p3 color class.""" from ..cat import WHITES from .srgb import SRGB, lin_srgb, gam_srgb -from .. import algebra as alg from ..types import Vector -from typing import cast - -RGB_TO_XYZ = [ - [0.4865709486482161, 0.26566769316909306, 0.1982172852343625], - [0.22897456406974875, 0.6917385218365063, 0.079286914093745], - [-3.972075516933487e-17, 0.04511338185890263, 1.043944368900976] -] - -XYZ_TO_RGB = [ - [2.4934969119414254, -0.9313836179191239, -0.40271078445071684], - [-0.8294889695615747, 1.7626640603183465, 0.02362468584194358], - [0.03584583024378446, -0.0761723892680418, 0.9568845240076872] -] - - -def lin_p3_to_xyz(rgb: Vector) -> Vector: - """ - Convert an array of linear-light image-p3 values to CIE XYZ using D65 (no chromatic adaptation). - - http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html - """ - - # 0 was computed as -3.972075516933488e-17 - return cast(Vector, alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1)) - - -def xyz_to_lin_p3(xyz: Vector) -> Vector: - """Convert XYZ to linear-light P3.""" - - return cast(Vector, alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1)) - - -def lin_p3(rgb: Vector) -> Vector: - """Convert an array of image-p3 RGB values in the range 0.0 - 1.0 to linear light (un-corrected) form.""" - - return lin_srgb(rgb) # same as sRGB - - -def gam_p3(rgb: Vector) -> Vector: - """Convert an array of linear-light image-p3 RGB in the range 0.0-1.0 to gamma corrected form.""" - - return gam_srgb(rgb) # same as sRGB class DisplayP3(SRGB): """Display-p3 class.""" - BASE = "xyz-d65" + BASE = "display-p3-linear" NAME = "display-p3" WHITE = WHITES['2deg']['D65'] @@ -58,10 +15,10 @@ class DisplayP3(SRGB): def to_base(cls, coords: Vector) -> Vector: """To XYZ from Display P3.""" - return lin_p3_to_xyz(lin_p3(coords)) + return lin_srgb(coords) @classmethod def from_base(cls, coords: Vector) -> Vector: """From XYZ to Display P3.""" - return gam_p3(xyz_to_lin_p3(coords)) + return gam_srgb(coords) diff --git a/lib/coloraide/spaces/display_p3_linear.py b/lib/coloraide/spaces/display_p3_linear.py new file mode 100644 index 00000000..31c61b5a --- /dev/null +++ b/lib/coloraide/spaces/display_p3_linear.py @@ -0,0 +1,55 @@ +"""Linear Display-p3 color class.""" +from ..cat import WHITES +from .srgb import SRGB +from .. import algebra as alg +from ..types import Vector + +RGB_TO_XYZ = [ + [0.4865709486482161, 0.26566769316909306, 0.1982172852343625], + [0.22897456406974875, 0.6917385218365063, 0.079286914093745], + [-3.972075516933487e-17, 0.04511338185890263, 1.043944368900976] +] + +XYZ_TO_RGB = [ + [2.4934969119414254, -0.9313836179191239, -0.40271078445071684], + [-0.8294889695615747, 1.7626640603183465, 0.02362468584194358], + [0.03584583024378446, -0.0761723892680418, 0.9568845240076872] +] + + +def lin_p3_to_xyz(rgb: Vector) -> Vector: + """ + Convert an array of linear-light image-p3 values to CIE XYZ using D65 (no chromatic adaptation). + + http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + """ + + # 0 was computed as -3.972075516933488e-17 + return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + + +def xyz_to_lin_p3(xyz: Vector) -> Vector: + """Convert XYZ to linear-light P3.""" + + return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + + +class DisplayP3Linear(SRGB): + """Linear Display-p3 class.""" + + BASE = "xyz-d65" + NAME = "display-p3-linear" + SERIALIZE = ('--display-p3-linear',) + WHITE = WHITES['2deg']['D65'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To XYZ from Linear Display P3.""" + + return lin_p3_to_xyz(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From XYZ to Linear Display P3.""" + + return xyz_to_lin_p3(coords) diff --git a/lib/coloraide/spaces/hsi.py b/lib/coloraide/spaces/hsi.py new file mode 100644 index 00000000..3bbfd022 --- /dev/null +++ b/lib/coloraide/spaces/hsi.py @@ -0,0 +1,119 @@ +""" +HSI class. + +https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation +""" +from ..spaces import Space, Cylindrical, WHITES +from ..channels import Channel, FLG_ANGLE +from .. import algebra as alg +from .. import util +from ..types import Vector + + +def srgb_to_hsi(rgb: Vector) -> Vector: + """SRGB to HSI.""" + + r, g, b = rgb + h = alg.NaN + s = 0.0 + mx = max(rgb) + mn = min(rgb) + i = sum(rgb) * 1 / 3 + s = 0 if i == 0 else 1 - (mn / i) + c = mx - mn + + if c != 0.0: + if mx == r: + h = (g - b) / c + elif mx == g: + h = (b - r) / c + 2.0 + else: + h = (r - g) / c + 4.0 + h *= 60.0 + + return [util.constrain_hue(h), s, i] + + +def hsi_to_srgb(hsi: Vector) -> Vector: + """HSI to RGB.""" + + h, s, i = hsi + h = h % 360 + h /= 60 + z = 1 - abs(h % 2 - 1) + c = (3 * i * s) / (1 + z) + x = c * z + + if alg.is_nan(h): # pragma: no cover + # In our current setup, this will not occur. If colors are naturally achromatic, + # they will resolve to zeros automatically even without this check. This case + # would be a shortcut normally. + # + # Unnatural cases, such as explicitly setting of hue to undefined, could cause this, + # but the conversion pipeline converts all undefined values to zero. We'd have to + # encounter a natural case due to conversion to or from HSI mid pipeline to trigger + # this, and we are not currently in a position where that would occur with sRGB being + # the only pass-through. + rgb = [0.0] * 3 + elif 0 <= h <= 1: + rgb = [c, x, 0] + elif 1 <= h <= 2: + rgb = [x, c, 0] + elif 2 <= h <= 3: + rgb = [0, c, x] + elif 3 <= h <= 4: + rgb = [0, x, c] + elif 4 <= h <= 5: + rgb = [x, 0, c] + else: + rgb = [c, 0, x] + m = i * (1 - s) + + return [chan + m for chan in rgb] + + +class HSI(Cylindrical, Space): + """HSI class.""" + + BASE = "srgb" + NAME = "hsi" + SERIALIZE = ("--hsi",) + CHANNELS = ( + Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("s", 0.0, 1.0, bound=True), + Channel("i", 0.0, 1.0, bound=True) + ) + CHANNEL_ALIASES = { + "hue": "h", + "saturation": "s", + "intensity": "i" + } + WHITE = WHITES['2deg']['D65'] + GAMUT_CHECK = "srgb" + + @classmethod + def normalize(cls, coords: Vector) -> Vector: + """On color update.""" + + h, s, i = alg.no_nans(coords[:-1]) + h = h % 360 + h /= 60 + z = 1 - abs(h % 2 - 1) + c = (3 * i * s) / (1 + z) + + if c == 0: + coords[0] = alg.NaN + + return coords + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To sRGB from HSI.""" + + return hsi_to_srgb(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From sRGB to HSI.""" + + return srgb_to_hsi(coords) diff --git a/lib/coloraide/spaces/hsl/__init__.py b/lib/coloraide/spaces/hsl/__init__.py index 7f83ea7d..552fb2e1 100644 --- a/lib/coloraide/spaces/hsl/__init__.py +++ b/lib/coloraide/spaces/hsl/__init__.py @@ -1,11 +1,10 @@ """HSL class.""" from ...spaces import Space, Cylindrical from ...cat import WHITES -from ...gamut.bounds import GamutBound, FLG_ANGLE, FLG_PERCENT +from ...channels import Channel, FLG_ANGLE, FLG_PERCENT from ... import util from ... import algebra as alg from ...types import Vector -from typing import Tuple def srgb_to_hsl(rgb: Vector) -> Vector: @@ -59,7 +58,11 @@ class HSL(Cylindrical, Space): BASE = "srgb" NAME = "hsl" SERIALIZE = ("--hsl",) - CHANNEL_NAMES = ("h", "s", "l") + CHANNELS = ( + Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("s", 0.0, 1.0, bound=True, flags=FLG_PERCENT), + Channel("l", 0.0, 1.0, bound=True, flags=FLG_PERCENT) + ) CHANNEL_ALIASES = { "hue": "h", "saturation": "s", @@ -68,57 +71,15 @@ class HSL(Cylindrical, Space): WHITE = WHITES['2deg']['D65'] GAMUT_CHECK = "srgb" - BOUNDS = ( - GamutBound(0.0, 360.0, FLG_ANGLE), - GamutBound(0.0, 1.0, FLG_PERCENT), - GamutBound(0.0, 1.0, FLG_PERCENT) - ) - - @property - def h(self) -> float: - """Hue channel.""" - - return self._coords[0] - - @h.setter - def h(self, value: float) -> None: - """Shift the hue.""" - - self._coords[0] = value - - @property - def s(self) -> float: - """Saturation channel.""" - - return self._coords[1] - - @s.setter - def s(self, value: float) -> None: - """Saturate or unsaturate the color by the given factor.""" - - self._coords[1] = value - - @property - def l(self) -> float: - """Lightness channel.""" - - return self._coords[2] - - @l.setter - def l(self, value: float) -> None: - """Set lightness channel.""" - - self._coords[2] = value - @classmethod - def null_adjust(cls, coords: Vector, alpha: float) -> Tuple[Vector, float]: + def normalize(cls, coords: Vector) -> Vector: """On color update.""" coords = alg.no_nans(coords) if coords[1] == 0 or coords[2] in (0, 1): coords[0] = alg.NaN - return coords, alg.no_nan(alpha) + return coords @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/hsl/css.py b/lib/coloraide/spaces/hsl/css.py index 12587fc4..bef88a27 100644 --- a/lib/coloraide/spaces/hsl/css.py +++ b/lib/coloraide/spaces/hsl/css.py @@ -12,8 +12,9 @@ class HSL(base.HSL): """HSL class.""" + @classmethod def to_string( - self, + cls, parent: 'Color', *, alpha: Optional[bool] = None, diff --git a/lib/coloraide/spaces/hsluv.py b/lib/coloraide/spaces/hsluv.py index b84791dd..fd428d23 100644 --- a/lib/coloraide/spaces/hsluv.py +++ b/lib/coloraide/spaces/hsluv.py @@ -26,7 +26,7 @@ """ from ..spaces import Space, Cylindrical from ..cat import WHITES -from ..gamut.bounds import GamutBound, FLG_ANGLE, FLG_OPT_PERCENT +from ..channels import Channel, FLG_ANGLE from .lch import ACHROMATIC_THRESHOLD from .lab import EPSILON, KAPPA from .srgb_linear import XYZ_TO_RGB @@ -34,7 +34,7 @@ from .. import util from .. import algebra as alg from ..types import Vector -from typing import List, Dict, Tuple +from typing import List, Dict def length_of_ray_until_intersect(theta: float, line: Dict[str, float]) -> float: @@ -114,7 +114,11 @@ class HSLuv(Cylindrical, Space): BASE = 'lchuv' NAME = "hsluv" SERIALIZE = ("--hsluv",) - CHANNEL_NAMES = ("h", "s", "l") + CHANNELS = ( + Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("s", 0.0, 100.0, bound=True), + Channel("l", 0.0, 100.0, bound=True) + ) CHANNEL_ALIASES = { "hue": "h", "saturation": "s", @@ -123,56 +127,14 @@ class HSLuv(Cylindrical, Space): WHITE = WHITES['2deg']['D65'] GAMUT_CHECK = "srgb" - BOUNDS = ( - GamutBound(0.0, 360.0, FLG_ANGLE), - GamutBound(0.0, 100.0, FLG_OPT_PERCENT), - GamutBound(0.0, 100.0, FLG_OPT_PERCENT) - ) - - @property - def h(self) -> float: - """Hue channel.""" - - return self._coords[0] - - @h.setter - def h(self, value: float) -> None: - """Shift the hue.""" - - self._coords[0] = value - - @property - def s(self) -> float: - """Saturation channel.""" - - return self._coords[1] - - @s.setter - def s(self, value: float) -> None: - """Saturate or unsaturate the color by the given factor.""" - - self._coords[1] = value - - @property - def l(self) -> float: - """Lightness channel.""" - - return self._coords[2] - - @l.setter - def l(self, value: float) -> None: - """Set lightness channel.""" - - self._coords[2] = value - @classmethod - def null_adjust(cls, coords: Vector, alpha: float) -> Tuple[Vector, float]: + def normalize(cls, coords: Vector) -> Vector: """On color update.""" coords = alg.no_nans(coords) if coords[1] == 0 or coords[2] > (100 - 1e-7) or coords[2] < 1e-08: coords[0] = alg.NaN - return coords, alg.no_nan(alpha) + return coords @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/hsv.py b/lib/coloraide/spaces/hsv.py index 19b6a19c..26b5b747 100644 --- a/lib/coloraide/spaces/hsv.py +++ b/lib/coloraide/spaces/hsv.py @@ -1,11 +1,10 @@ """HSV class.""" from ..spaces import Space, Cylindrical from ..cat import WHITES -from ..gamut.bounds import GamutBound, FLG_ANGLE, FLG_OPT_PERCENT +from ..channels import Channel, FLG_ANGLE from .. import util from .. import algebra as alg from ..types import Vector -from typing import Tuple def hsv_to_hsl(hsv: Vector) -> Vector: @@ -49,7 +48,11 @@ class HSV(Cylindrical, Space): BASE = "hsl" NAME = "hsv" SERIALIZE = ("--hsv",) - CHANNEL_NAMES = ("h", "s", "v") + CHANNELS = ( + Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("s", 0.0, 1.0, bound=True), + Channel("v", 0.0, 1.0, bound=True) + ) CHANNEL_ALIASES = { "hue": "h", "saturation": "s", @@ -58,57 +61,15 @@ class HSV(Cylindrical, Space): GAMUT_CHECK = "srgb" WHITE = WHITES['2deg']['D65'] - BOUNDS = ( - GamutBound(0.0, 360.0, FLG_ANGLE), - GamutBound(0.0, 1.0, FLG_OPT_PERCENT), - GamutBound(0.0, 1.0, FLG_OPT_PERCENT) - ) - - @property - def h(self) -> float: - """Hue channel.""" - - return self._coords[0] - - @h.setter - def h(self, value: float) -> None: - """Shift the hue.""" - - self._coords[0] = value - - @property - def s(self) -> float: - """Saturation channel.""" - - return self._coords[1] - - @s.setter - def s(self, value: float) -> None: - """Saturate or unsaturate the color by the given factor.""" - - self._coords[1] = value - - @property - def v(self) -> float: - """Value channel.""" - - return self._coords[2] - - @v.setter - def v(self, value: float) -> None: - """Set value channel.""" - - self._coords[2] = value - @classmethod - def null_adjust(cls, coords: Vector, alpha: float) -> Tuple[Vector, float]: + def normalize(cls, coords: Vector) -> Vector: """On color update.""" coords = alg.no_nans(coords) if coords[1] == 0: coords[0] = alg.NaN - return coords, alg.no_nan(alpha) + return coords @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/hunter_lab.py b/lib/coloraide/spaces/hunter_lab.py new file mode 100644 index 00000000..0854be22 --- /dev/null +++ b/lib/coloraide/spaces/hunter_lab.py @@ -0,0 +1,74 @@ +""" +Hunter Lab class. + +https://support.hunterlab.com/hc/en-us/articles/203997095-Hunter-Lab-Color-Scale-an08-96a2 +""" +from ..cat import WHITES +from ..spaces.lab import Lab +from .. import algebra as alg +from .. import util +from ..types import Vector, VectorLike +from ..channels import Channel, FLG_MIRROR_PERCENT + +# Values for the original Hunter Lab with illuminant C. +# Used to calculate an appropriate `Ka` and `Kb` for whatever white point we are using. +CXN = 98.04 +CYN = 100.0 +CZN = 118.11 +CKA = 175.0 +CKB = 70.0 + + +def xyz_to_hlab(xyz: Vector, white: VectorLike) -> Vector: + """Convert XYZ to Hunter Lab.""" + + xn, yn, zn = alg.multiply(util.xy_to_xyz(white), 100, dims=alg.D1_SC) + ka = CKA * alg.nth_root(xn / CXN, 2) + kb = CKB * alg.nth_root(zn / CZN, 2) + x, y, z = alg.multiply(xyz, 100, dims=alg.D1_SC) + l = alg.nth_root(y / yn, 2) + a = b = 0.0 + if l != 0: + a = ka * (x / xn - y / yn) / l + b = kb * (y / yn - z / zn) / l + return [l * 100, a, b] + + +def hlab_to_xyz(hlab: Vector, white: VectorLike) -> Vector: + """Convert Hunter Lab to XYZ.""" + + xn, yn, zn = alg.multiply(util.xy_to_xyz(white), 100, dims=alg.D1_SC) + ka = CKA * alg.nth_root(xn / CXN, 2) + kb = CKB * alg.nth_root(zn / CZN, 2) + l, a, b = hlab + l /= 100 + y = (l ** 2) * yn + x = (((a * l) / ka) + (y / yn)) * xn + z = (((b * l) / kb) - (y / yn)) * -zn + return alg.divide([x, y, z], 100, dims=alg.D1_SC) + + +class HunterLab(Lab): + """Hunter Lab class.""" + + BASE = 'xyz-d65' + NAME = "hunter-lab" + SERIALIZE = ("--hunter-lab",) + WHITE = WHITES['2deg']['D65'] + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("a", -210.0, 210.0, flags=FLG_MIRROR_PERCENT), + Channel("b", -210.0, 210.0, flags=FLG_MIRROR_PERCENT) + ) + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To XYZ from Hunter Lab.""" + + return hlab_to_xyz(coords, cls.white()) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From XYZ to Hunter Lab.""" + + return xyz_to_hlab(coords, cls.white()) diff --git a/lib/coloraide/spaces/hwb/__init__.py b/lib/coloraide/spaces/hwb/__init__.py index 2138fc16..f9e1f3c2 100644 --- a/lib/coloraide/spaces/hwb/__init__.py +++ b/lib/coloraide/spaces/hwb/__init__.py @@ -1,10 +1,9 @@ """HWB class.""" from ...spaces import Space, Cylindrical from ...cat import WHITES -from ...gamut.bounds import GamutBound, FLG_ANGLE, FLG_PERCENT +from ...channels import Channel, FLG_ANGLE, FLG_PERCENT from ... import algebra as alg from ...types import Vector -from typing import Tuple def hwb_to_hsv(hwb: Vector) -> Vector: @@ -39,7 +38,11 @@ class HWB(Cylindrical, Space): BASE = "hsv" NAME = "hwb" SERIALIZE = ("--hwb",) - CHANNEL_NAMES = ("h", "w", "b") + CHANNELS = ( + Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("w", 0.0, 1.0, bound=True, flags=FLG_PERCENT), + Channel("b", 0.0, 1.0, bound=True, flags=FLG_PERCENT) + ) CHANNEL_ALIASES = { "hue": "h", "whiteness": "w", @@ -48,56 +51,14 @@ class HWB(Cylindrical, Space): GAMUT_CHECK = "srgb" WHITE = WHITES['2deg']['D65'] - BOUNDS = ( - GamutBound(0.0, 360.0, FLG_ANGLE), - GamutBound(0.0, 1.0, FLG_PERCENT), - GamutBound(0.0, 1.0, FLG_PERCENT) - ) - - @property - def h(self) -> float: - """Hue channel.""" - - return self._coords[0] - - @h.setter - def h(self, value: float) -> None: - """Shift the hue.""" - - self._coords[0] = value - - @property - def w(self) -> float: - """Whiteness channel.""" - - return self._coords[1] - - @w.setter - def w(self, value: float) -> None: - """Set whiteness channel.""" - - self._coords[1] = value - - @property - def b(self) -> float: - """Blackness channel.""" - - return self._coords[2] - - @b.setter - def b(self, value: float) -> None: - """Set blackness channel.""" - - self._coords[2] = value - @classmethod - def null_adjust(cls, coords: Vector, alpha: float) -> Tuple[Vector, float]: + def normalize(cls, coords: Vector) -> Vector: """On color update.""" coords = alg.no_nans(coords) if coords[1] + coords[2] >= 1: coords[0] = alg.NaN - return coords, alg.no_nan(alpha) + return coords @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/hwb/css.py b/lib/coloraide/spaces/hwb/css.py index 4d68efdc..91f412e5 100644 --- a/lib/coloraide/spaces/hwb/css.py +++ b/lib/coloraide/spaces/hwb/css.py @@ -12,8 +12,9 @@ class HWB(base.HWB): """HWB class.""" + @classmethod def to_string( - self, + cls, parent: 'Color', *, alpha: Optional[bool] = None, diff --git a/lib/coloraide/spaces/ictcp.py b/lib/coloraide/spaces/ictcp.py index 3d4abe53..a8afa35d 100644 --- a/lib/coloraide/spaces/ictcp.py +++ b/lib/coloraide/spaces/ictcp.py @@ -5,11 +5,10 @@ """ from ..spaces import Space, Labish from ..cat import WHITES -from ..gamut.bounds import GamutUnbound, FLG_OPT_PERCENT +from ..channels import Channel, FLG_MIRROR_PERCENT from .. import util from .. import algebra as alg from ..types import Vector -from typing import cast # All PQ Values are equivalent to defaults as stated in link below: # https://en.wikipedia.org/wiki/High-dynamic-range_video#Perceptual_quantizer @@ -55,13 +54,13 @@ def ictcp_to_xyz_d65(ictcp: Vector) -> Vector: """From ICtCp to XYZ.""" # Convert to LMS prime - pqlms = cast(Vector, alg.dot(ictcp_to_lms_p_mi, ictcp, dims=alg.D2_D1)) + pqlms = alg.dot(ictcp_to_lms_p_mi, ictcp, dims=alg.D2_D1) # Decode PQ LMS to LMS lms = util.pq_st2084_eotf(pqlms) # Convert back to absolute XYZ D65 - absxyz = cast(Vector, alg.dot(lms_to_xyz_mi, lms, dims=alg.D2_D1)) + absxyz = alg.dot(lms_to_xyz_mi, lms, dims=alg.D2_D1) # Convert back to normal XYZ D65 return util.absxyzd65_to_xyz_d65(absxyz) @@ -74,13 +73,13 @@ def xyz_d65_to_ictcp(xyzd65: Vector) -> Vector: absxyz = util.xyz_d65_to_absxyzd65(xyzd65) # Convert to LMS - lms = cast(Vector, alg.dot(xyz_to_lms_m, absxyz, dims=alg.D2_D1)) + lms = alg.dot(xyz_to_lms_m, absxyz, dims=alg.D2_D1) # PQ encode the LMS pqlms = util.pq_st2084_inverse_eotf(lms) # Calculate Izazbz - return cast(Vector, alg.dot(lms_p_to_ictcp_m, pqlms, dims=alg.D2_D1)) + return alg.dot(lms_p_to_ictcp_m, pqlms, dims=alg.D2_D1) class ICtCp(Labish, Space): @@ -89,50 +88,12 @@ class ICtCp(Labish, Space): BASE = "xyz-d65" NAME = "ictcp" SERIALIZE = ("--ictcp",) - CHANNEL_NAMES = ("i", "ct", "cp") - WHITE = WHITES['2deg']['D65'] - - BOUNDS = ( - GamutUnbound(0.0, 1.0, FLG_OPT_PERCENT), - GamutUnbound(-0.5, 0.5), - GamutUnbound(-0.5, 0.5) + CHANNELS = ( + Channel("i", 0.0, 1.0), + Channel("ct", -0.5, 0.5, flags=FLG_MIRROR_PERCENT), + Channel("cp", -0.5, 0.5, flags=FLG_MIRROR_PERCENT) ) - - @property - def i(self) -> float: - """`I` channel.""" - - return self._coords[0] - - @i.setter - def i(self, value: float) -> None: - """Set `I` channel.""" - - self._coords[0] = value - - @property - def ct(self) -> float: - """`Ct` axis.""" - - return self._coords[1] - - @ct.setter - def ct(self, value: float) -> None: - """`Ct` axis.""" - - self._coords[1] = value - - @property - def cp(self) -> float: - """`Cp` axis.""" - - return self._coords[2] - - @cp.setter - def cp(self, value: float) -> None: - """Set `Cp` axis.""" - - self._coords[2] = value + WHITE = WHITES['2deg']['D65'] @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/igpgtg.py b/lib/coloraide/spaces/igpgtg.py new file mode 100644 index 00000000..22ace159 --- /dev/null +++ b/lib/coloraide/spaces/igpgtg.py @@ -0,0 +1,85 @@ +""" +The IgPgTg color space. + +https://www.ingentaconnect.com/content/ist/jpi/2020/00000003/00000002/art00002# +""" +from ..spaces import Space, Labish +from ..channels import Channel, FLG_MIRROR_PERCENT +from ..cat import WHITES +from .. import algebra as alg +from ..types import Vector +from typing import Tuple + +XYZ_TO_LMS = [ + [2.968, 2.741, -0.649], + [1.237, 5.969, -0.173], + [-0.318, 0.387, 2.311] +] + +LMS_TO_XYZ = [ + [0.4343486855574634, -0.20636237011428418, 0.10653033617352772], + [-0.08785463778363381, 0.20846346647992345, -0.009066845616854866], + [0.07447971736457795, -0.06330532030466152, 0.44889031421761344] +] + +LMS_TO_IGPGTG = [ + [0.117, 1.464, 0.13], + [8.285, -8.361, 21.4], + [-1.208, 2.412, -36.53] +] + +IGPGTG_TO_LMS = [ + [0.5818464618992484, 0.1233185479390782, 0.07431308420320765], + [0.6345481937914158, -0.009437923746683553, -0.003270744675229782], + [0.022656986516578225, -0.0047011518748263665, -0.030048158824914562] +] + + +def xyz_to_igpgtg(xyz: Vector) -> Vector: + """XYZ to IgPgTg.""" + + lms_in = alg.dot(XYZ_TO_LMS, xyz, dims=alg.D2_D1) + lms = [ + alg.npow(lms_in[0] / 18.36, 0.427), + alg.npow(lms_in[1] / 21.46, 0.427), + alg.npow(lms_in[2] / 19435, 0.427) + ] + return alg.dot(LMS_TO_IGPGTG, lms, dims=alg.D2_D1) + + +def igpgtg_to_xyz(itp: Vector) -> Vector: + """IgPgTg to XYZ.""" + + lms = alg.dot(IGPGTG_TO_LMS, itp, dims=alg.D2_D1) + lms_in = [ + alg.nth_root(lms[0], 0.427) * 18.36, + alg.nth_root(lms[1], 0.427) * 21.46, + alg.nth_root(lms[2], 0.427) * 19435 + ] + return alg.dot(LMS_TO_XYZ, lms_in, dims=alg.D2_D1) + + +class IgPgTg(Labish, Space): + """The IgPgTg class.""" + + BASE = "xyz-d65" + NAME = "igpgtg" + SERIALIZE = ("--igpgtg",) # type: Tuple[str, ...] + CHANNELS = ( + Channel("ig", 0.0, 1.0), + Channel("pg", -1.0, 1.0, flags=FLG_MIRROR_PERCENT), + Channel("tg", -1.0, 1.0, flags=FLG_MIRROR_PERCENT) + ) + WHITE = WHITES['2deg']['D65'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To XYZ.""" + + return igpgtg_to_xyz(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From XYZ.""" + + return xyz_to_igpgtg(coords) diff --git a/lib/coloraide/spaces/ipt.py b/lib/coloraide/spaces/ipt.py new file mode 100644 index 00000000..ad737938 --- /dev/null +++ b/lib/coloraide/spaces/ipt.py @@ -0,0 +1,93 @@ +""" +The IPT color space. + +https://www.researchgate.net/publication/\ +221677980_Development_and_Testing_of_a_Color_Space_IPT_with_Improved_Hue_Uniformity. +""" +from ..spaces import Space, Labish +from ..channels import Channel, FLG_MIRROR_PERCENT +from ..cat import WHITES +from .. import algebra as alg +from ..types import Vector +from typing import Tuple + +# The IPT algorithm requires the use of the Hunt-Pointer-Estevez matrix, +# but it was originally calculated with the assumption of a slightly different +# D65 white point than what we use. +# +# - Theirs: [0.9504, 1.0, 1.0889] -> xy chromaticity points (0.3127035830618893, 0.32902313032606195) +# - Ours: [0.9504559270516716, 1, 1.0890577507598784] -> calculated from xy chromaticity points [0.31270, 0.32900] +# +# For a good conversion, our options were to either set the color space to a slightly different D65 white point, +# or adjust the algorithm such that it accounted for the difference in white point. We chose the latter. +# +# ``` +# theirs = alg.diag([0.9504, 1.0, 1.0889]) +# ours = alg.diag(white_d65) +# return alg.multi_dot([MHPE, theirs, alg.inv(ours)]) +# ``` +# +# Below is the Hunter-Pointer-Estevez matrix combined with our white point compensation. +XYZ_TO_LMS = [ + [0.4001764512951712, 0.7075, -0.08068831054981859], + [-0.2279865839462744, 1.15, 0.061191135138152386], + [0.0, 0.0, 0.9182669691320122] +] + +LMS_TO_XYZ = [ + [1.8503518239760197, -1.1383686221417688, 0.23844898940542367], + [0.36683077517134854, 0.6438845448402356, -0.01067344358438], + [0.0, 0.0, 1.089007917757562] +] + +LMS_P_TO_IPT = [ + [0.4, 0.4, 0.2], + [4.455, -4.851, 0.396], + [0.8056, 0.3572, -1.1628] +] + +IPT_TO_LMS_P = [ + [1.0000000000000004, 0.0975689305146139, 0.2052264331645916], + [0.9999999999999997, -0.1138764854731471, 0.13321715836999803], + [1.0, 0.0326151099170664, -0.6768871830691793] +] + + +def xyz_to_ipt(xyz: Vector) -> Vector: + """XYZ to IPT.""" + + lms_p = [alg.npow(c, 0.43) for c in alg.dot(XYZ_TO_LMS, xyz, dims=alg.D2_D1)] + return alg.dot(LMS_P_TO_IPT, lms_p, dims=alg.D2_D1) + + +def ipt_to_xyz(ipt: Vector) -> Vector: + """IPT to XYZ.""" + + lms = [alg.nth_root(c, 0.43) for c in alg.dot(IPT_TO_LMS_P, ipt, dims=alg.D2_D1)] + return alg.dot(LMS_TO_XYZ, lms, dims=alg.D2_D1) + + +class IPT(Labish, Space): + """The IPT class.""" + + BASE = "xyz-d65" + NAME = "ipt" + SERIALIZE = ("--ipt",) # type: Tuple[str, ...] + CHANNELS = ( + Channel("i", 0.0, 1.0, bound=True), + Channel("p", -1.0, 1.0, bound=True, flags=FLG_MIRROR_PERCENT), + Channel("t", -1.0, 1.0, bound=True, flags=FLG_MIRROR_PERCENT) + ) + WHITE = WHITES['2deg']['D65'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To XYZ.""" + + return ipt_to_xyz(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From XYZ.""" + + return xyz_to_ipt(coords) diff --git a/lib/coloraide/spaces/jzazbz.py b/lib/coloraide/spaces/jzazbz.py index 5e03871d..54a74a94 100644 --- a/lib/coloraide/spaces/jzazbz.py +++ b/lib/coloraide/spaces/jzazbz.py @@ -11,7 +11,7 @@ BT.2048 says media white Y=203 at PQ 58 This is confirmed here: https://www.itu.int/dms_pub/itu-r/opb/rep/R-REP-BT.2408-3-2019-PDF-E.pdf -It is tough to tell who is correct as everything passes through the Matlab scripts fine as it +It is tough to tell who is correct as everything passes through the MATLAB scripts fine as it just scales the results differently, so forward and backwards translation comes out great regardless, but looking at the images in the spec, it seems the scaling using Y=203 at PQ 58 may be correct. It is almost certain that some scaling is being applied and that applying none is almost certainly wrong. @@ -20,11 +20,10 @@ """ from ..spaces import Space, Labish from ..cat import WHITES -from ..gamut.bounds import GamutUnbound, FLG_OPT_PERCENT +from ..channels import Channel, FLG_MIRROR_PERCENT from .. import util from .. import algebra as alg from ..types import Vector -from typing import cast B = 1.15 G = 0.66 @@ -82,13 +81,13 @@ def jzazbz_to_xyz_d65(jzazbz: Vector) -> Vector: iz = (jz + D0) / (1 + D - D * (jz + D0)) # Convert to LMS prime - pqlms = cast(Vector, alg.dot(izazbz_to_lms_p_mi, [iz, az, bz], dims=alg.D2_D1)) + pqlms = alg.dot(izazbz_to_lms_p_mi, [iz, az, bz], dims=alg.D2_D1) # Decode PQ LMS to LMS lms = util.pq_st2084_eotf(pqlms, m2=M2) # Convert back to absolute XYZ D65 - xm, ym, za = cast(Vector, alg.dot(lms_to_xyz_mi, lms, dims=alg.D2_D1)) + xm, ym, za = alg.dot(lms_to_xyz_mi, lms, dims=alg.D2_D1) xa = (xm + ((B - 1) * za)) / B ya = (ym + ((G - 1) * xa)) / G @@ -105,13 +104,13 @@ def xyz_d65_to_jzazbz(xyzd65: Vector) -> Vector: ym = (G * ya) - ((G - 1) * xa) # Convert to LMS - lms = cast(Vector, alg.dot(xyz_to_lms_m, [xm, ym, za], dims=alg.D2_D1)) + lms = alg.dot(xyz_to_lms_m, [xm, ym, za], dims=alg.D2_D1) # PQ encode the LMS pqlms = util.pq_st2084_inverse_eotf(lms, m2=M2) # Calculate Izazbz - iz, az, bz = cast(Vector, alg.dot(lms_p_to_izazbz_m, pqlms, dims=alg.D2_D1)) + iz, az, bz = alg.dot(lms_p_to_izazbz_m, pqlms, dims=alg.D2_D1) # Calculate Jz jz = ((1 + D) * iz) / (1 + (D * iz)) - D0 @@ -124,7 +123,11 @@ class Jzazbz(Labish, Space): BASE = "xyz-d65" NAME = "jzazbz" SERIALIZE = ("--jzazbz",) - CHANNEL_NAMES = ("jz", "az", "bz") + CHANNELS = ( + Channel("jz", 0.0, 1.0), + Channel("az", -0.5, 0.5, flags=FLG_MIRROR_PERCENT), + Channel("bz", -0.5, 0.5, flags=FLG_MIRROR_PERCENT) + ) CHANNEL_ALIASES = { "lightness": 'jz', "a": 'az', @@ -132,48 +135,6 @@ class Jzazbz(Labish, Space): } WHITE = WHITES['2deg']['D65'] - BOUNDS = ( - GamutUnbound(0.0, 1.0, FLG_OPT_PERCENT), - GamutUnbound(-0.5, 0.5), - GamutUnbound(-0.5, 0.5) - ) - - @property - def jz(self) -> float: - """Jz channel.""" - - return self._coords[0] - - @jz.setter - def jz(self, value: float) -> None: - """Set jz channel.""" - - self._coords[0] = value - - @property - def az(self) -> float: - """Az axis.""" - - return self._coords[1] - - @az.setter - def az(self, value: float) -> None: - """Az axis.""" - - self._coords[1] = value - - @property - def bz(self) -> float: - """Bz axis.""" - - return self._coords[2] - - @bz.setter - def bz(self, value: float) -> None: - """Set bz axis.""" - - self._coords[2] = value - @classmethod def to_base(cls, coords: Vector) -> Vector: """To XYZ from Jzazbz.""" diff --git a/lib/coloraide/spaces/jzczhz.py b/lib/coloraide/spaces/jzczhz.py index d2621a44..9301a357 100644 --- a/lib/coloraide/spaces/jzczhz.py +++ b/lib/coloraide/spaces/jzczhz.py @@ -5,12 +5,11 @@ """ from ..spaces import Space, Lchish from ..cat import WHITES -from ..gamut.bounds import GamutUnbound, FLG_ANGLE, FLG_OPT_PERCENT +from ..channels import Channel, FLG_ANGLE from .. import util import math from .. import algebra as alg from ..types import Vector -from typing import Tuple ACHROMATIC_THRESHOLD = 0.0003 @@ -55,7 +54,11 @@ class JzCzhz(Lchish, Space): BASE = "jzazbz" NAME = "jzczhz" SERIALIZE = ("--jzczhz",) - CHANNEL_NAMES = ("jz", "cz", "hz") + CHANNELS = ( + Channel("jz", 0.0, 1.0), + Channel("cz", 0.0, 0.5, limit=(0.0, None)), + Channel("hz", 0.0, 360.0, flags=FLG_ANGLE) + ) CHANNEL_ALIASES = { "lightness": "jz", "chroma": "cz", @@ -63,57 +66,15 @@ class JzCzhz(Lchish, Space): } WHITE = WHITES['2deg']['D65'] - BOUNDS = ( - GamutUnbound(0.0, 1.0, FLG_OPT_PERCENT), - GamutUnbound(0.0, 1.0), - GamutUnbound(0.0, 360.0, FLG_ANGLE) - ) - - @property - def jz(self) -> float: - """Jz.""" - - return self._coords[0] - - @jz.setter - def jz(self, value: float) -> None: - """Set jz.""" - - self._coords[0] = value - - @property - def cz(self) -> float: - """Chroma.""" - - return self._coords[1] - - @cz.setter - def cz(self, value: float) -> None: - """Set chroma.""" - - self._coords[1] = alg.clamp(value, 0.0) - - @property - def hz(self) -> float: - """Hue.""" - - return self._coords[2] - - @hz.setter - def hz(self, value: float) -> None: - """Set hue.""" - - self._coords[2] = value - @classmethod - def null_adjust(cls, coords: Vector, alpha: float) -> Tuple[Vector, float]: + def normalize(cls, coords: Vector) -> Vector: """On color update.""" coords = alg.no_nans(coords) if coords[1] < ACHROMATIC_THRESHOLD: coords[2] = alg.NaN - return coords, alg.no_nan(alpha) + return coords @classmethod def hue_name(cls) -> str: diff --git a/lib/coloraide/spaces/lab/__init__.py b/lib/coloraide/spaces/lab/__init__.py index c946c794..9b29ed22 100644 --- a/lib/coloraide/spaces/lab/__init__.py +++ b/lib/coloraide/spaces/lab/__init__.py @@ -1,11 +1,10 @@ """Lab class.""" from ...spaces import Space, Labish from ...cat import WHITES -from ...gamut.bounds import GamutUnbound, FLG_OPT_PERCENT +from ...channels import Channel, FLG_OPT_PERCENT, FLG_MIRROR_PERCENT from ... import util from ... import algebra as alg from ...types import VectorLike, Vector -from typing import cast EPSILON = 216 / 24389 # `6^3 / 29^3` EPSILON3 = 6 / 29 # Cube root of EPSILON @@ -38,7 +37,7 @@ def lab_to_xyz(lab: Vector, white: VectorLike) -> Vector: ] # Compute XYZ by scaling `xyz` by reference `white` - return cast(Vector, alg.multiply(xyz, util.xy_to_xyz(white), dims=alg.D1)) + return alg.multiply(xyz, util.xy_to_xyz(white), dims=alg.D1) def xyz_to_lab(xyz: Vector, white: VectorLike) -> Vector: @@ -52,7 +51,7 @@ def xyz_to_lab(xyz: Vector, white: VectorLike) -> Vector: """ # compute `xyz`, which is XYZ scaled relative to reference white - xyz = cast(Vector, alg.divide(xyz, util.xy_to_xyz(white), dims=alg.D1)) + xyz = alg.divide(xyz, util.xy_to_xyz(white), dims=alg.D1) # Compute `fx`, `fy`, and `fz` fx, fy, fz = [alg.cbrt(i) if i > EPSILON else (KAPPA * i + 16) / 116 for i in xyz] @@ -69,52 +68,15 @@ class Lab(Labish, Space): BASE = "xyz-d50" NAME = "lab" SERIALIZE = ("--lab",) - CHANNEL_NAMES = ("l", "a", "b") + CHANNELS = ( + Channel("l", 0.0, 100.0, flags=FLG_OPT_PERCENT), + Channel("a", -125.0, 125.0, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT), + Channel("b", -125.0, 125.0, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT) + ) CHANNEL_ALIASES = { "lightness": "l" } WHITE = WHITES['2deg']['D50'] - BOUNDS = ( - GamutUnbound(0.0, 100.0, FLG_OPT_PERCENT), - GamutUnbound(-125, 125), - GamutUnbound(-125, 125) - ) - - @property - def l(self) -> float: - """L channel.""" - - return self._coords[0] - - @l.setter - def l(self, value: float) -> None: - """Get true luminance.""" - - self._coords[0] = value - - @property - def a(self) -> float: - """A channel.""" - - return self._coords[1] - - @a.setter - def a(self, value: float) -> None: - """A axis.""" - - self._coords[1] = value - - @property - def b(self) -> float: - """B channel.""" - - return self._coords[2] - - @b.setter - def b(self, value: float) -> None: - """B axis.""" - - self._coords[2] = value @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/lab/css.py b/lib/coloraide/spaces/lab/css.py index 7af3d941..d70b4892 100644 --- a/lib/coloraide/spaces/lab/css.py +++ b/lib/coloraide/spaces/lab/css.py @@ -12,8 +12,9 @@ class Lab(base.Lab): """Lab class.""" + @classmethod def to_string( - self, + cls, parent: 'Color', *, alpha: Optional[bool] = None, @@ -32,7 +33,7 @@ def to_string( fit=fit, none=none, color=kwargs.get('color', False), - percent=True + percent=kwargs.get('percent', False), ) @classmethod diff --git a/lib/coloraide/spaces/lab_d65.py b/lib/coloraide/spaces/lab_d65.py index ba25b9f5..a66b2da6 100644 --- a/lib/coloraide/spaces/lab_d65.py +++ b/lib/coloraide/spaces/lab_d65.py @@ -1,6 +1,7 @@ """Lab D65 class.""" from ..cat import WHITES from .lab import Lab +from ..channels import Channel, FLG_MIRROR_PERCENT class LabD65(Lab): @@ -10,3 +11,8 @@ class LabD65(Lab): NAME = "lab-d65" SERIALIZE = ("--lab-d65",) WHITE = WHITES['2deg']['D65'] + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("a", -130.0, 130.0, flags=FLG_MIRROR_PERCENT), + Channel("b", -130.0, 130.0, flags=FLG_MIRROR_PERCENT) + ) diff --git a/lib/coloraide/spaces/lch/__init__.py b/lib/coloraide/spaces/lch/__init__.py index 3c14c448..02ebeca5 100644 --- a/lib/coloraide/spaces/lch/__init__.py +++ b/lib/coloraide/spaces/lch/__init__.py @@ -1,12 +1,11 @@ """Lch class.""" from ...spaces import Space, Lchish from ...cat import WHITES -from ...gamut.bounds import GamutUnbound, FLG_ANGLE, FLG_OPT_PERCENT +from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT from ... import util import math from ... import algebra as alg from ...types import Vector -from typing import Tuple ACHROMATIC_THRESHOLD = 0.0000000002 @@ -24,8 +23,7 @@ def lab_to_lch(lab: Vector) -> Vector: if c < ACHROMATIC_THRESHOLD: h = alg.NaN - test = [l, c, util.constrain_hue(h)] - return test + return [l, c, util.constrain_hue(h)] def lch_to_lab(lch: Vector) -> Vector: @@ -48,63 +46,26 @@ class Lch(Lchish, Space): BASE = "lab" NAME = "lch" SERIALIZE = ("--lch",) - CHANNEL_NAMES = ("l", "c", "h") + CHANNELS = ( + Channel("l", 0.0, 100.0, flags=FLG_OPT_PERCENT), + Channel("c", 0.0, 150.0, limit=(0.0, None), flags=FLG_OPT_PERCENT), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) CHANNEL_ALIASES = { "lightness": "l", "chroma": "c", "hue": "h" } WHITE = WHITES['2deg']['D50'] - BOUNDS = ( - GamutUnbound(0.0, 100.0, FLG_OPT_PERCENT), - GamutUnbound(0.0, 100.0), - GamutUnbound(0.0, 360.0, FLG_ANGLE) - ) - - @property - def l(self) -> float: - """Lightness.""" - - return self._coords[0] - - @l.setter - def l(self, value: float) -> None: - """Get true luminance.""" - - self._coords[0] = value - - @property - def c(self) -> float: - """Chroma.""" - - return self._coords[1] - - @c.setter - def c(self, value: float) -> None: - """chroma.""" - - self._coords[1] = alg.clamp(value, 0.0) - - @property - def h(self) -> float: - """Hue.""" - - return self._coords[2] - - @h.setter - def h(self, value: float) -> None: - """Shift the hue.""" - - self._coords[2] = value @classmethod - def null_adjust(cls, coords: Vector, alpha: float) -> Tuple[Vector, float]: + def normalize(cls, coords: Vector) -> Vector: """On color update.""" coords = alg.no_nans(coords) if coords[1] < ACHROMATIC_THRESHOLD: coords[2] = alg.NaN - return coords, alg.no_nan(alpha) + return coords @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/lch/css.py b/lib/coloraide/spaces/lch/css.py index 743eb850..4a9188fc 100644 --- a/lib/coloraide/spaces/lch/css.py +++ b/lib/coloraide/spaces/lch/css.py @@ -12,8 +12,9 @@ class Lch(base.Lch): """Lch class.""" + @classmethod def to_string( - self, + cls, parent: 'Color', *, alpha: Optional[bool] = None, @@ -32,7 +33,7 @@ def to_string( fit=fit, none=none, color=kwargs.get('color', False), - percent=True + percent=kwargs.get('percent', False) ) @classmethod diff --git a/lib/coloraide/spaces/lch99o.py b/lib/coloraide/spaces/lch99o.py index 644bf82b..5a122778 100644 --- a/lib/coloraide/spaces/lch99o.py +++ b/lib/coloraide/spaces/lch99o.py @@ -5,6 +5,7 @@ import math from .. import algebra as alg from ..types import Vector +from ..channels import Channel, FLG_ANGLE ACHROMATIC_THRESHOLD = 0.0000000002 @@ -45,6 +46,11 @@ class Lch99o(Lch): NAME = "lch99o" SERIALIZE = ("--lch99o",) WHITE = WHITES['2deg']['D65'] + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("c", 0.0, 60.0, limit=(0.0, None)), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/lch_d65.py b/lib/coloraide/spaces/lch_d65.py index 9c481577..c6f507d5 100644 --- a/lib/coloraide/spaces/lch_d65.py +++ b/lib/coloraide/spaces/lch_d65.py @@ -1,6 +1,7 @@ """Lch D65 class.""" from ..cat import WHITES from .lch import Lch +from ..channels import Channel, FLG_ANGLE class LchD65(Lch): @@ -10,3 +11,8 @@ class LchD65(Lch): NAME = "lch-d65" SERIALIZE = ("--lch-d65",) WHITE = WHITES['2deg']['D65'] + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("c", 0.0, 160.0, limit=(0.0, None)), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) diff --git a/lib/coloraide/spaces/lchuv.py b/lib/coloraide/spaces/lchuv.py index 39da8867..26927235 100644 --- a/lib/coloraide/spaces/lchuv.py +++ b/lib/coloraide/spaces/lchuv.py @@ -1,7 +1,7 @@ """LCH class.""" from ..spaces import Space from ..cat import WHITES -from ..gamut.bounds import GamutUnbound, FLG_ANGLE, FLG_OPT_PERCENT +from ..channels import Channel, FLG_ANGLE from .lch import Lch, ACHROMATIC_THRESHOLD from .. import util import math @@ -46,11 +46,10 @@ class Lchuv(Lch, Space): NAME = "lchuv" SERIALIZE = ("--lchuv",) WHITE = WHITES['2deg']['D65'] - - BOUNDS = ( - GamutUnbound(0, 100.0, FLG_OPT_PERCENT), - GamutUnbound(0.0, 176.0), - GamutUnbound(0.0, 360.0, FLG_ANGLE) + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("c", 0.0, 220.0, limit=(0.0, None)), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) ) @classmethod diff --git a/lib/coloraide/spaces/luv.py b/lib/coloraide/spaces/luv.py index dda7aff5..4779c2d7 100644 --- a/lib/coloraide/spaces/luv.py +++ b/lib/coloraide/spaces/luv.py @@ -5,7 +5,7 @@ """ from ..spaces import Space, Labish from ..cat import WHITES -from ..gamut.bounds import GamutUnbound, FLG_OPT_PERCENT +from ..channels import Channel, FLG_MIRROR_PERCENT from .lab import KAPPA, EPSILON, KE from .. import util from .. import algebra as alg @@ -60,54 +60,16 @@ class Luv(Labish, Space): BASE = "xyz-d65" NAME = "luv" SERIALIZE = ("--luv",) - CHANNEL_NAMES = ("l", "u", "v") + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("u", -215.0, 215.0, flags=FLG_MIRROR_PERCENT), + Channel("v", -215.0, 215.0, flags=FLG_MIRROR_PERCENT) + ) CHANNEL_ALIASES = { "lightness": "l" } WHITE = WHITES['2deg']['D65'] - BOUNDS = ( - GamutUnbound(0.0, 100.0, FLG_OPT_PERCENT), - GamutUnbound(-175.0, 175.0), - GamutUnbound(-175.0, 175.0) - ) - - @property - def l(self) -> float: - """L channel.""" - - return self._coords[0] - - @l.setter - def l(self, value: float) -> None: - """Get true luminance.""" - - self._coords[0] = value - - @property - def u(self) -> float: - """U channel.""" - - return self._coords[1] - - @u.setter - def u(self, value: float) -> None: - """U axis.""" - - self._coords[1] = value - - @property - def v(self) -> float: - """V channel.""" - - return self._coords[2] - - @v.setter - def v(self, value: float) -> None: - """V axis.""" - - self._coords[2] = value - @classmethod def to_base(cls, coords: Vector) -> Vector: """To XYZ D50 from Luv.""" diff --git a/lib/coloraide/spaces/okhsl.py b/lib/coloraide/spaces/okhsl.py index f5f7cb4c..b3bf7fc4 100644 --- a/lib/coloraide/spaces/okhsl.py +++ b/lib/coloraide/spaces/okhsl.py @@ -27,14 +27,15 @@ """ from ..spaces import Space, Cylindrical from ..cat import WHITES -from ..gamut.bounds import GamutBound, FLG_ANGLE, FLG_OPT_PERCENT +from ..channels import Channel, FLG_ANGLE from .oklab import oklab_to_linear_srgb +from .oklch import ACHROMATIC_THRESHOLD from .. import util import math import sys from .. import algebra as alg from ..types import Vector -from typing import Tuple, Optional +from typing import Optional FLT_MAX = sys.float_info.max @@ -334,7 +335,7 @@ def okhsl_to_oklab(hsl: Vector) -> Vector: L = toe_inv(l) a = b = 0.0 - if L != 0 and L != 1 and s != 0 and not alg.is_nan(h): + if L not in (0.0, 1.0) and s != 0.0 and not alg.is_nan(h): a_ = math.cos(2.0 * math.pi * h) b_ = math.sin(2.0 * math.pi * h) @@ -373,13 +374,16 @@ def okhsl_to_oklab(hsl: Vector) -> Vector: def oklab_to_okhsl(lab: Vector) -> Vector: """Oklab to Okhsl.""" - c = math.sqrt(lab[1] ** 2 + lab[2] ** 2) - h = alg.NaN L = lab[0] s = 0.0 + l = toe(L) - if c != 0 and L not in (0, 1): + c = math.sqrt(lab[1] ** 2 + lab[2] ** 2) + if c < ACHROMATIC_THRESHOLD: + c = 0 + + if l not in (0.0, 1.0) and c != 0: a_ = lab[1] / c b_ = lab[2] / c @@ -387,8 +391,6 @@ def oklab_to_okhsl(lab: Vector) -> Vector: c_0, c_mid, c_max = get_cs([L, a_, b_]) - # Inverse of the interpolation in `okhsl_to_srgb`: - mid = 0.8 mid_inv = 1.25 @@ -407,11 +409,6 @@ def oklab_to_okhsl(lab: Vector) -> Vector: t = (c - k_0) / (k_1 + k_2 * (c - k_0)) s = mid + 0.2 * t - l = toe(L) - - if s == 0: - h = alg.NaN - return [util.constrain_hue(h * 360), s, l] @@ -421,7 +418,11 @@ class Okhsl(Cylindrical, Space): BASE = "oklab" NAME = "okhsl" SERIALIZE = ("--okhsl",) - CHANNEL_NAMES = ("h", "s", "l") + CHANNELS = ( + Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("s", 0.0, 1.0, bound=True), + Channel("l", 0.0, 1.0, bound=True) + ) CHANNEL_ALIASES = { "hue": "h", "saturation": "s", @@ -430,56 +431,14 @@ class Okhsl(Cylindrical, Space): WHITE = WHITES['2deg']['D65'] GAMUT_CHECK = "srgb" - BOUNDS = ( - GamutBound(0.0, 360.0, FLG_ANGLE), - GamutBound(0.0, 1.0, FLG_OPT_PERCENT), - GamutBound(0.0, 1.0, FLG_OPT_PERCENT) - ) - - @property - def h(self) -> float: - """Hue channel.""" - - return self._coords[0] - - @h.setter - def h(self, value: float) -> None: - """Shift the hue.""" - - self._coords[0] = value - - @property - def s(self) -> float: - """Saturation channel.""" - - return self._coords[1] - - @s.setter - def s(self, value: float) -> None: - """Saturate or unsaturate the color by the given factor.""" - - self._coords[1] = value - - @property - def l(self) -> float: - """Lightness channel.""" - - return self._coords[2] - - @l.setter - def l(self, value: float) -> None: - """Set lightness channel.""" - - self._coords[2] = value - @classmethod - def null_adjust(cls, coords: Vector, alpha: float) -> Tuple[Vector, float]: + def normalize(cls, coords: Vector) -> Vector: """On color update.""" coords = alg.no_nans(coords) - if coords[1] == 0 or coords[2] in (0, 1): + if coords[2] in (0.0, 1.0) or coords[1] == 0.0: coords[0] = alg.NaN - return coords, alg.no_nan(alpha) + return coords @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/okhsv.py b/lib/coloraide/spaces/okhsv.py index 1e9a7aa8..6a84de81 100644 --- a/lib/coloraide/spaces/okhsv.py +++ b/lib/coloraide/spaces/okhsv.py @@ -27,26 +27,28 @@ """ from ..spaces import Space, Cylindrical from ..cat import WHITES -from ..gamut.bounds import GamutBound, FLG_ANGLE, FLG_OPT_PERCENT +from ..channels import FLG_ANGLE, Channel from .. import util from .oklab import oklab_to_linear_srgb from .okhsl import toe, toe_inv, find_cusp, to_st +from .oklch import ACHROMATIC_THRESHOLD import math from .. import algebra as alg from ..types import Vector -from typing import Tuple def okhsv_to_oklab(hsv: Vector) -> Vector: """Convert from Okhsv to Oklab.""" h, s, v = hsv + h = alg.no_nan(h) h = h / 360.0 l = toe_inv(v) a = b = 0.0 - if l != 0 and s != 0 and not alg.is_nan(h): + # Avoid processing gray or colors with undefined hues + if v != 0.0 and s != 0.0 and not alg.is_nan(h): a_ = math.cos(2.0 * math.pi * h) b_ = math.sin(2.0 * math.pi * h) @@ -88,14 +90,16 @@ def okhsv_to_oklab(hsv: Vector) -> Vector: def oklab_to_okhsv(lab: Vector) -> Vector: """Oklab to Okhsv.""" - c = math.sqrt(lab[1] ** 2 + lab[2] ** 2) l = lab[0] - h = alg.NaN s = 0.0 v = toe(l) - if c != 0 and l != 0 and l != 1: + c = math.sqrt(lab[1] ** 2 + lab[2] ** 2) + if c < ACHROMATIC_THRESHOLD: + c = 0 + + if l not in (0.0, 1.0) and c != 0: a_ = lab[1] / c b_ = lab[2] / c @@ -128,9 +132,6 @@ def oklab_to_okhsv(lab: Vector) -> Vector: v = l / l_v s = (s_0 + t_max) * c_v / ((t_max * s_0) + t_max * k * c_v) - if s == 0: - h = alg.NaN - return [util.constrain_hue(h * 360), s, v] @@ -140,7 +141,11 @@ class Okhsv(Cylindrical, Space): BASE = "oklab" NAME = "okhsv" SERIALIZE = ("--okhsv",) - CHANNEL_NAMES = ("h", "s", "v") + CHANNELS = ( + Channel("h", 0.0, 360.0, bound=True, flags=FLG_ANGLE), + Channel("s", 0.0, 1.0, bound=True), + Channel("v", 0.0, 1.0, bound=True) + ) CHANNEL_ALIASES = { "hue": "h", "saturation": "s", @@ -149,56 +154,14 @@ class Okhsv(Cylindrical, Space): WHITE = WHITES['2deg']['D65'] GAMUT_CHECK = "srgb" - BOUNDS = ( - GamutBound(0.0, 360.0, FLG_ANGLE), - GamutBound(0.0, 1.0, FLG_OPT_PERCENT), - GamutBound(0.0, 1.0, FLG_OPT_PERCENT) - ) - - @property - def h(self) -> float: - """Hue channel.""" - - return self._coords[0] - - @h.setter - def h(self, value: float) -> None: - """Shift the hue.""" - - self._coords[0] = value - - @property - def s(self) -> float: - """Saturation channel.""" - - return self._coords[1] - - @s.setter - def s(self, value: float) -> None: - """Saturate or unsaturate the color by the given factor.""" - - self._coords[1] = value - - @property - def v(self) -> float: - """Value channel.""" - - return self._coords[2] - - @v.setter - def v(self, value: float) -> None: - """Set value channel.""" - - self._coords[2] = value - @classmethod - def null_adjust(cls, coords: Vector, alpha: float) -> Tuple[Vector, float]: + def normalize(cls, coords: Vector) -> Vector: """On color update.""" coords = alg.no_nans(coords) - if coords[1] == 0: + if coords[2] == 0 or coords[1] == 0.0: coords[0] = alg.NaN - return coords, alg.no_nan(alpha) + return coords @classmethod def to_base(cls, okhsv: Vector) -> Vector: diff --git a/lib/coloraide/spaces/oklab/__init__.py b/lib/coloraide/spaces/oklab/__init__.py index 207e3fd6..e4486fef 100644 --- a/lib/coloraide/spaces/oklab/__init__.py +++ b/lib/coloraide/spaces/oklab/__init__.py @@ -27,10 +27,9 @@ """ from ...spaces import Space, Labish from ...cat import WHITES -from ...gamut.bounds import GamutUnbound, FLG_OPT_PERCENT +from ...channels import Channel, FLG_OPT_PERCENT, FLG_MIRROR_PERCENT from ... import algebra as alg from ...types import Vector -from typing import cast # sRGB Linear to LMS SRGBL_TO_LMS = [ @@ -78,52 +77,40 @@ def oklab_to_linear_srgb(lab: Vector) -> Vector: """Convert from Oklab to linear sRGB.""" - return cast( - Vector, - alg.dot( - LMS_TO_SRGBL, - [c ** 3 for c in cast(Vector, alg.dot(OKLAB_TO_LMS3, lab, dims=alg.D2_D1))], - dims=alg.D2_D1 - ) + return alg.dot( + LMS_TO_SRGBL, + [c ** 3 for c in alg.dot(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], + dims=alg.D2_D1 ) def linear_srgb_to_oklab(rgb: Vector) -> Vector: # pragma: no cover """Linear sRGB to Oklab.""" - return cast( - Vector, - alg.dot( - LMS3_TO_OKLAB, - [alg.cbrt(c) for c in cast(Vector, alg.dot(SRGBL_TO_LMS, rgb, dims=alg.D2_D1))], - dims=alg.D2_D1 - ) + return alg.dot( + LMS3_TO_OKLAB, + [alg.cbrt(c) for c in alg.dot(SRGBL_TO_LMS, rgb, dims=alg.D2_D1)], + dims=alg.D2_D1 ) def oklab_to_xyz_d65(lab: Vector) -> Vector: """Convert from Oklab to XYZ D65.""" - return cast( - Vector, - alg.dot( - LMS_TO_XYZD65, - [c ** 3 for c in cast(Vector, alg.dot(OKLAB_TO_LMS3, lab, dims=alg.D2_D1))], - dims=alg.D2_D1 - ) + return alg.dot( + LMS_TO_XYZD65, + [c ** 3 for c in alg.dot(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)], + dims=alg.D2_D1 ) def xyz_d65_to_oklab(xyz: Vector) -> Vector: """XYZ D65 to Oklab.""" - return cast( - Vector, - alg.dot( - LMS3_TO_OKLAB, - [alg.cbrt(c) for c in cast(Vector, alg.dot(XYZD65_TO_LMS, xyz, dims=alg.D2_D1))], - dims=alg.D2_D1 - ) + return alg.dot( + LMS3_TO_OKLAB, + [alg.cbrt(c) for c in alg.dot(XYZD65_TO_LMS, xyz, dims=alg.D2_D1)], + dims=alg.D2_D1 ) @@ -133,54 +120,16 @@ class Oklab(Labish, Space): BASE = "xyz-d65" NAME = "oklab" SERIALIZE = ("--oklab",) - CHANNEL_NAMES = ("l", "a", "b") + CHANNELS = ( + Channel("l", 0.0, 1.0, flags=FLG_OPT_PERCENT), + Channel("a", -0.4, 0.4, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT), + Channel("b", -0.4, 0.4, flags=FLG_MIRROR_PERCENT | FLG_OPT_PERCENT) + ) CHANNEL_ALIASES = { "lightness": "l" } WHITE = WHITES['2deg']['D65'] - BOUNDS = ( - GamutUnbound(0.0, 1.0, FLG_OPT_PERCENT), - GamutUnbound(-0.5, 0.5), - GamutUnbound(-0.5, 0.5) - ) - - @property - def l(self) -> float: - """L channel.""" - - return self._coords[0] - - @l.setter - def l(self, value: float) -> None: - """Get true luminance.""" - - self._coords[0] = value - - @property - def a(self) -> float: - """A channel.""" - - return self._coords[1] - - @a.setter - def a(self, value: float) -> None: - """A axis.""" - - self._coords[1] = value - - @property - def b(self) -> float: - """B channel.""" - - return self._coords[2] - - @b.setter - def b(self, value: float) -> None: - """B axis.""" - - self._coords[2] = value - @classmethod def to_base(cls, oklab: Vector) -> Vector: """To XYZ.""" diff --git a/lib/coloraide/spaces/oklab/css.py b/lib/coloraide/spaces/oklab/css.py index 7d0d0439..d0a56e36 100644 --- a/lib/coloraide/spaces/oklab/css.py +++ b/lib/coloraide/spaces/oklab/css.py @@ -12,8 +12,9 @@ class Oklab(base.Oklab): """Oklab class.""" + @classmethod def to_string( - self, + cls, parent: 'Color', *, alpha: Optional[bool] = None, @@ -32,7 +33,7 @@ def to_string( fit=fit, none=none, color=kwargs.get('color', False), - percent=True + percent=kwargs.get('percent', False) ) @classmethod diff --git a/lib/coloraide/spaces/oklch/__init__.py b/lib/coloraide/spaces/oklch/__init__.py index 9551c473..17348876 100644 --- a/lib/coloraide/spaces/oklch/__init__.py +++ b/lib/coloraide/spaces/oklch/__init__.py @@ -25,12 +25,11 @@ """ from ...spaces import Space, Lchish from ...cat import WHITES -from ...gamut.bounds import GamutUnbound, FLG_ANGLE, FLG_OPT_PERCENT +from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT from ... import util import math from ... import algebra as alg from ...types import Vector -from typing import Tuple ACHROMATIC_THRESHOLD = 0.000002 @@ -71,7 +70,11 @@ class Oklch(Lchish, Space): BASE = "oklab" NAME = "oklch" SERIALIZE = ("--oklch",) - CHANNEL_NAMES = ("l", "c", "h") + CHANNELS = ( + Channel("l", 0.0, 1.0, flags=FLG_OPT_PERCENT), + Channel("c", 0.0, 0.4, limit=(0.0, None), flags=FLG_OPT_PERCENT), + Channel("h", 0.0, 360.0, flags=FLG_ANGLE) + ) CHANNEL_ALIASES = { "lightness": "l", "chroma": "c", @@ -79,57 +82,15 @@ class Oklch(Lchish, Space): } WHITE = WHITES['2deg']['D65'] - BOUNDS = ( - GamutUnbound(0.0, 1.0, FLG_OPT_PERCENT), - GamutUnbound(0.0, 1.0), - GamutUnbound(0.0, 360.0, FLG_ANGLE) - ) - - @property - def l(self) -> float: - """Lightness.""" - - return self._coords[0] - - @l.setter - def l(self, value: float) -> None: - """Get true luminance.""" - - self._coords[0] = value - - @property - def c(self) -> float: - """Chroma.""" - - return self._coords[1] - - @c.setter - def c(self, value: float) -> None: - """chroma.""" - - self._coords[1] = alg.clamp(value, 0.0) - - @property - def h(self) -> float: - """Hue.""" - - return self._coords[2] - - @h.setter - def h(self, value: float) -> None: - """Shift the hue.""" - - self._coords[2] = value - @classmethod - def null_adjust(cls, coords: Vector, alpha: float) -> Tuple[Vector, float]: + def normalize(cls, coords: Vector) -> Vector: """On color update.""" coords = alg.no_nans(coords) if coords[1] < ACHROMATIC_THRESHOLD: coords[2] = alg.NaN - return coords, alg.no_nan(alpha) + return coords @classmethod def to_base(cls, oklch: Vector) -> Vector: diff --git a/lib/coloraide/spaces/oklch/css.py b/lib/coloraide/spaces/oklch/css.py index 3e32e0cb..3d0145ea 100644 --- a/lib/coloraide/spaces/oklch/css.py +++ b/lib/coloraide/spaces/oklch/css.py @@ -12,8 +12,9 @@ class Oklch(base.Oklch): """Oklch class.""" + @classmethod def to_string( - self, + cls, parent: 'Color', *, alpha: Optional[bool] = None, @@ -32,7 +33,7 @@ def to_string( fit=fit, none=none, color=kwargs.get('color', False), - percent=True + percent=kwargs.get('percent', False) ) @classmethod diff --git a/lib/coloraide/spaces/orgb.py b/lib/coloraide/spaces/orgb.py new file mode 100644 index 00000000..6ca672fa --- /dev/null +++ b/lib/coloraide/spaces/orgb.py @@ -0,0 +1,87 @@ +""" +ORGB color space. + +https://graphics.stanford.edu/~boulos/papers/orgb_sig.pdf +""" +import math +from .. import algebra as alg +from ..spaces import Space, Labish +from ..types import Vector +from ..cat import WHITES +from ..channels import Channel, FLG_MIRROR_PERCENT + +RGB_TO_LC1C2 = [ + [0.2990, 0.5870, 0.1140], + [0.5000, 0.5000, -1.0000], + [0.8660, -0.8660, 0.0000] +] + +LC1C2_TO_RGB = alg.inv(RGB_TO_LC1C2) + + +def rotate(v: Vector, d: float) -> Vector: + """Rotate the vector.""" + + m = alg.identity(3) + m[1][1:] = math.cos(d), -math.sin(d) + m[2][1:] = math.sin(d), math.cos(d) + return alg.dot(m, v, dims=alg.D2_D1) + + +def srgb_to_orgb(rgb: Vector) -> Vector: + """SRGB to ORGB.""" + + lcc = alg.dot(RGB_TO_LC1C2, rgb, dims=alg.D2_D1) + theta = math.atan2(lcc[2], lcc[1]) + theta0 = theta + atheta = abs(theta) + if atheta < (math.pi / 3): + theta0 = (3 / 2) * theta + elif (math.pi / 3) <= atheta <= math.pi: + theta0 = math.copysign((math.pi / 2) + (3 / 4) * (atheta - math.pi / 3), theta) + + return rotate(lcc, theta0 - theta) + + +def orgb_to_srgb(lcc: Vector) -> Vector: + """ORGB to sRGB.""" + + theta0 = math.atan2(lcc[2], lcc[1]) + theta = theta0 + atheta0 = abs(theta0) + if atheta0 < (math.pi / 2): + theta = (2 / 3) * theta0 + elif (math.pi / 2) <= atheta0 <= math.pi: + theta = math.copysign((math.pi / 3) + (4 / 3) * (atheta0 - math.pi / 2), theta0) + + return alg.dot(LC1C2_TO_RGB, rotate(lcc, theta - theta0)) + + +class ORGB(Labish, Space): + """ORGB color class.""" + + BASE = 'srgb' + NAME = "orgb" + SERIALIZE = ("--orgb",) + WHITE = WHITES['2deg']['D65'] + EXTENDED_RANGE = True + CHANNELS = ( + Channel("l", 0.0, 1.0, bound=True), + Channel("cyb", -1.0, 1.0, bound=True, flags=FLG_MIRROR_PERCENT), + Channel("crg", -1.0, 1.0, bound=True, flags=FLG_MIRROR_PERCENT) + ) + CHANNEL_ALIASES = { + "luma": "l" + } + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To base from oRGB.""" + + return orgb_to_srgb(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From base to oRGB.""" + + return srgb_to_orgb(coords) diff --git a/lib/coloraide/spaces/prismatic.py b/lib/coloraide/spaces/prismatic.py new file mode 100644 index 00000000..6b8703cc --- /dev/null +++ b/lib/coloraide/spaces/prismatic.py @@ -0,0 +1,64 @@ +""" +Prismatic color space. + +Creates a Maxwell color triangle with a lightness component. + +http://psgraphics.blogspot.com/2015/10/prismatic-color-model.html +https://studylib.net/doc/14656976/the-prismatic-color-space-for-rgb-computations +""" +from ..spaces import Space +from ..channels import Channel +from ..cat import WHITES +from ..types import Vector +from typing import Tuple + + +def srgb_to_lrgb(rgb: Vector) -> Vector: + """Convert sRGB to Prismatic.""" + + l = max(rgb) + s = sum(rgb) + return [l] + ([(c / s) for c in rgb] if s != 0 else [0, 0, 0]) + + +def lrgb_to_srgb(lrgb: Vector) -> Vector: + """Convert Prismatic to sRGB.""" + + rgb = lrgb[1:] + l = lrgb[0] + mx = max(rgb) + return [(l * c) / mx for c in rgb] if mx != 0 else [0, 0, 0] + + +class Prismatic(Space): + """The Prismatic color class.""" + + BASE = "srgb" + NAME = "prismatic" + SERIALIZE = ("--prismatic",) # type: Tuple[str, ...] + EXTENDED_RANGE = False + CHANNELS = ( + Channel("l", 0.0, 1.0, bound=True), + Channel("r", 0.0, 1.0, bound=True), + Channel("g", 0.0, 1.0, bound=True), + Channel("b", 0.0, 1.0, bound=True) + ) + CHANNEL_ALIASES = { + "lightness": 'l', + "red": 'r', + "green": 'g', + "blue": 'b' + } + WHITE = WHITES['2deg']['D65'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To sRGB.""" + + return lrgb_to_srgb(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From sRGB.""" + + return srgb_to_lrgb(coords) diff --git a/lib/coloraide/spaces/prophoto_rgb.py b/lib/coloraide/spaces/prophoto_rgb.py index f640bce7..88b8fa42 100644 --- a/lib/coloraide/spaces/prophoto_rgb.py +++ b/lib/coloraide/spaces/prophoto_rgb.py @@ -3,40 +3,10 @@ from .srgb import SRGB from .. import algebra as alg from ..types import Vector -from typing import cast ET = 1 / 512 ET2 = 16 / 512 -RGB_TO_XYZ = [ - [0.7977604896723027, 0.13518583717574031, 0.0313493495815248], - [0.2880711282292934, 0.7118432178101014, 8.565396060525902e-05], - [0.0, 0.0, 0.8251046025104601] -] - -XYZ_TO_RGB = [ - [1.3457989731028281, -0.2555801000799754, -0.05110628506753401], - [-0.5446224939028347, 1.5082327413132781, 0.02053603239147973], - [0.0, 0.0, 1.2119675456389454] -] - - -def lin_prophoto_to_xyz(rgb: Vector) -> Vector: - """ - Convert an array of linear-light prophoto-rgb values to CIE XYZ using D50.D50. - - (so no chromatic adaptation needed afterwards) - http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html - """ - - return cast(Vector, alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1)) - - -def xyz_to_lin_prophoto(xyz: Vector) -> Vector: - """Convert XYZ to linear-light prophoto-rgb.""" - - return cast(Vector, alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1)) - def lin_prophoto(rgb: Vector) -> Vector: """ @@ -79,7 +49,7 @@ def gam_prophoto(rgb: Vector) -> Vector: class ProPhotoRGB(SRGB): """Pro Photo RGB class.""" - BASE = "xyz-d50" + BASE = "prophoto-rgb-linear" NAME = "prophoto-rgb" WHITE = WHITES['2deg']['D50'] @@ -87,10 +57,10 @@ class ProPhotoRGB(SRGB): def to_base(cls, coords: Vector) -> Vector: """To XYZ from Pro Photo RGB.""" - return lin_prophoto_to_xyz(lin_prophoto(coords)) + return lin_prophoto(coords) @classmethod def from_base(cls, coords: Vector) -> Vector: """From XYZ to Pro Photo RGB.""" - return gam_prophoto(xyz_to_lin_prophoto(coords)) + return gam_prophoto(coords) diff --git a/lib/coloraide/spaces/prophoto_rgb_linear.py b/lib/coloraide/spaces/prophoto_rgb_linear.py new file mode 100644 index 00000000..95fcc688 --- /dev/null +++ b/lib/coloraide/spaces/prophoto_rgb_linear.py @@ -0,0 +1,55 @@ +"""Linear Pro Photo RGB color class.""" +from ..cat import WHITES +from .srgb import SRGB +from .. import algebra as alg +from ..types import Vector + +RGB_TO_XYZ = [ + [0.7977604896723027, 0.13518583717574031, 0.0313493495815248], + [0.2880711282292934, 0.7118432178101014, 8.565396060525902e-05], + [0.0, 0.0, 0.8251046025104601] +] + +XYZ_TO_RGB = [ + [1.3457989731028281, -0.2555801000799754, -0.05110628506753401], + [-0.5446224939028347, 1.5082327413132781, 0.02053603239147973], + [0.0, 0.0, 1.2119675456389454] +] + + +def lin_prophoto_to_xyz(rgb: Vector) -> Vector: + """ + Convert an array of linear-light prophoto-rgb values to CIE XYZ using D50.D50. + + (so no chromatic adaptation needed afterwards) + http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + """ + + return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + + +def xyz_to_lin_prophoto(xyz: Vector) -> Vector: + """Convert XYZ to linear-light prophoto-rgb.""" + + return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + + +class ProPhotoRGBLinear(SRGB): + """Linear Pro Photo RGB class.""" + + BASE = "xyz-d50" + NAME = "prophoto-rgb-linear" + SERIALIZE = ('--prophoto-rgb-linear',) + WHITE = WHITES['2deg']['D50'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To XYZ from Linear Pro Photo RGB.""" + + return lin_prophoto_to_xyz(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From XYZ to Linear Pro Photo RGB.""" + + return xyz_to_lin_prophoto(coords) diff --git a/lib/coloraide/spaces/rec2020.py b/lib/coloraide/spaces/rec2020.py index eb7a716a..84a0b31e 100644 --- a/lib/coloraide/spaces/rec2020.py +++ b/lib/coloraide/spaces/rec2020.py @@ -4,24 +4,11 @@ import math from .. import algebra as alg from ..types import Vector -from typing import cast ALPHA = 1.09929682680944 BETA = 0.018053968510807 BETA45 = 0.018053968510807 * 4.5 -RGB_TO_XYZ = [ - [0.6369580483012914, 0.14461690358620832, 0.16888097516417208], - [0.2627002120112671, 0.6779980715188708, 0.05930171646986195], - [4.994106574466076e-17, 0.028072693049087428, 1.0609850577107909] -] - -XYZ_TO_RGB = [ - [1.7166511879712674, -0.35567078377639233, -0.25336628137365974], - [-0.6666843518324892, 1.6164812366349395, 0.015768545813911124], - [0.017639857445310787, -0.04277061325780853, 0.9421031212354739] -] - def lin_2020(rgb: Vector) -> Vector: """ @@ -59,27 +46,10 @@ def gam_2020(rgb: Vector) -> Vector: return result -def lin_2020_to_xyz(rgb: Vector) -> Vector: - """ - Convert an array of linear-light rec-2020 values to CIE XYZ using D65. - - (no chromatic adaptation) - http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html - """ - - return cast(Vector, alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1)) - - -def xyz_to_lin_2020(xyz: Vector) -> Vector: - """Convert XYZ to linear-light rec-2020.""" - - return cast(Vector, alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1)) - - class Rec2020(SRGB): """Rec 2020 class.""" - BASE = "xyz-d65" + BASE = "rec2020-linear" NAME = "rec2020" WHITE = WHITES['2deg']['D65'] @@ -87,10 +57,10 @@ class Rec2020(SRGB): def to_base(cls, coords: Vector) -> Vector: """To XYZ from Rec 2020.""" - return lin_2020_to_xyz(lin_2020(coords)) + return lin_2020(coords) @classmethod def from_base(cls, coords: Vector) -> Vector: """From XYZ to Rec 2020.""" - return gam_2020(xyz_to_lin_2020(coords)) + return gam_2020(coords) diff --git a/lib/coloraide/spaces/rec2020_linear.py b/lib/coloraide/spaces/rec2020_linear.py new file mode 100644 index 00000000..64280eab --- /dev/null +++ b/lib/coloraide/spaces/rec2020_linear.py @@ -0,0 +1,59 @@ +"""Linear Rec 2020 color class.""" +from ..cat import WHITES +from .srgb import SRGB +from .. import algebra as alg +from ..types import Vector + +ALPHA = 1.09929682680944 +BETA = 0.018053968510807 +BETA45 = 0.018053968510807 * 4.5 + +RGB_TO_XYZ = [ + [0.6369580483012914, 0.14461690358620832, 0.16888097516417208], + [0.2627002120112671, 0.6779980715188708, 0.05930171646986195], + [4.994106574466076e-17, 0.028072693049087428, 1.0609850577107909] +] + +XYZ_TO_RGB = [ + [1.7166511879712674, -0.35567078377639233, -0.25336628137365974], + [-0.6666843518324892, 1.6164812366349395, 0.015768545813911124], + [0.017639857445310787, -0.04277061325780853, 0.9421031212354739] +] + + +def lin_2020_to_xyz(rgb: Vector) -> Vector: + """ + Convert an array of linear-light rec-2020 values to CIE XYZ using D65. + + (no chromatic adaptation) + http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + """ + + return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) + + +def xyz_to_lin_2020(xyz: Vector) -> Vector: + """Convert XYZ to linear-light rec-2020.""" + + return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) + + +class Rec2020Linear(SRGB): + """Linear Rec 2020 class.""" + + BASE = "xyz-d65" + NAME = "rec2020-linear" + SERIALIZE = ('--rec2020-linear',) + WHITE = WHITES['2deg']['D65'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To XYZ from Linear Rec 2020.""" + + return lin_2020_to_xyz(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From XYZ to Linear Rec 2020.""" + + return xyz_to_lin_2020(coords) diff --git a/lib/coloraide/spaces/rec2100pq.py b/lib/coloraide/spaces/rec2100pq.py new file mode 100644 index 00000000..955f0056 --- /dev/null +++ b/lib/coloraide/spaces/rec2100pq.py @@ -0,0 +1,31 @@ +""" +Rec 2100 PQ color class. + +https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.2100-2-201807-I!!PDF-E.pdf +""" +from ..cat import WHITES +from .srgb import SRGB +from .. import algebra as alg +from ..types import Vector +from .. import util + + +class Rec2100PQ(SRGB): + """Rec 2100 PQ class.""" + + BASE = "rec2020-linear" + NAME = "rec2100pq" + SERIALIZE = ('--rec2100pq',) + WHITE = WHITES['2deg']['D65'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To XYZ from Rec 2100 PQ.""" + + return alg.divide(util.pq_st2084_eotf(coords), util.YW, dims=alg.D1_SC) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From XYZ to Rec 2100 PQ.""" + + return util.pq_st2084_inverse_eotf(alg.multiply(coords, util.YW, dims=alg.D1_SC)) diff --git a/lib/coloraide/spaces/rlab.py b/lib/coloraide/spaces/rlab.py new file mode 100644 index 00000000..cad7e5d2 --- /dev/null +++ b/lib/coloraide/spaces/rlab.py @@ -0,0 +1,72 @@ +""" +RLAB. + +https://scholarworks.rit.edu/cgi/viewcontent.cgi?article=1153&context=article +https://www.imaging.org/site/PDFS/Papers/1997/RP-0-67/2368.pdf +""" +from ..cat import WHITES +from ..spaces.lab import Lab +from .. import algebra as alg +from ..types import Vector +from ..channels import Channel, FLG_MIRROR_PERCENT + +XYZ_TO_XYZ_REF = [ + [1.0521266389510715, 2.220446049250313e-16, 0.0], + [0.0, 1.0, 2.414043899674756e-19], + [0.0, 0.0, 0.9182249511582473] +] + +XYZ_REF_TO_XYZ = [ + [0.9504559270516716, -2.110436108208428e-16, 5.548406636355788e-35], + [0.0, 1.0, -2.629033219615395e-19], + [0.0, 0.0, 1.0890577507598784] +] + +EXP = 2.3 + + +def rlab_to_xyz(rlab: Vector) -> Vector: + """RLAB to XYZ.""" + + l, a, b = rlab + yr = l / 100 + xr = alg.npow((a / 430) + yr, EXP) + zr = alg.npow(yr - (b / 170), EXP) + return alg.dot(XYZ_REF_TO_XYZ, [xr, alg.npow(yr, EXP), zr], dims=alg.D2_D1) + + +def xyz_to_rlab(xyz: Vector) -> Vector: + """XYZ to RLAB.""" + + xyz_ref = alg.dot(XYZ_TO_XYZ_REF, xyz, dims=alg.D2_D1) + xr, yr, zr = [alg.nth_root(c, EXP) for c in xyz_ref] + l = 100 * yr + a = 430 * (xr - yr) + b = 170 * (yr - zr) + return [l, a, b] + + +class RLAB(Lab): + """RLAB class.""" + + BASE = 'xyz-d65' + NAME = "rlab" + SERIALIZE = ("--rlab",) + WHITE = WHITES['2deg']['D65'] + CHANNELS = ( + Channel("l", 0.0, 100.0), + Channel("a", -125.0, 125.0, flags=FLG_MIRROR_PERCENT), + Channel("b", -125.0, 125.0, flags=FLG_MIRROR_PERCENT) + ) + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To XYZ from Hunter Lab.""" + + return rlab_to_xyz(coords) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From XYZ to Hunter Lab.""" + + return xyz_to_rlab(coords) diff --git a/lib/coloraide/spaces/srgb/__init__.py b/lib/coloraide/spaces/srgb/__init__.py index 8520401d..1df3e2aa 100644 --- a/lib/coloraide/spaces/srgb/__init__.py +++ b/lib/coloraide/spaces/srgb/__init__.py @@ -1,7 +1,7 @@ """SRGB color class.""" from ...spaces import Space from ...cat import WHITES -from ...gamut.bounds import GamutBound, FLG_OPT_PERCENT +from ...channels import Channel, FLG_OPT_PERCENT from ... import algebra as alg from ...types import Vector import math @@ -48,7 +48,11 @@ class SRGB(Space): BASE = "srgb-linear" NAME = "srgb" - CHANNEL_NAMES = ("r", "g", "b") + CHANNELS = ( + Channel("r", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT), + Channel("g", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT), + Channel("b", 0.0, 1.0, bound=True, flags=FLG_OPT_PERCENT) + ) CHANNEL_ALIASES = { "red": 'r', "green": 'g', @@ -57,47 +61,6 @@ class SRGB(Space): WHITE = WHITES['2deg']['D65'] EXTENDED_RANGE = True - BOUNDS = ( - GamutBound(0.0, 1.0, FLG_OPT_PERCENT), - GamutBound(0.0, 1.0, FLG_OPT_PERCENT), - GamutBound(0.0, 1.0, FLG_OPT_PERCENT) - ) - - @property - def r(self) -> float: - """Adjust red.""" - - return self._coords[0] - - @r.setter - def r(self, value: float) -> None: - """Adjust red.""" - - self._coords[0] = value - - @property - def g(self) -> float: - """Adjust green.""" - - return self._coords[1] - - @g.setter - def g(self, value: float) -> None: - """Adjust green.""" - - self._coords[1] = value - - @property - def b(self) -> float: - """Adjust blue.""" - - return self._coords[2] - - @b.setter - def b(self, value: float) -> None: - """Adjust blue.""" - - self._coords[2] = value @classmethod def from_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/spaces/srgb/css.py b/lib/coloraide/spaces/srgb/css.py index fbe62a53..4019619f 100644 --- a/lib/coloraide/spaces/srgb/css.py +++ b/lib/coloraide/spaces/srgb/css.py @@ -12,8 +12,9 @@ class SRGB(base.SRGB): """SRGB class.""" + @classmethod def to_string( - self, + cls, parent: 'Color', *, alpha: Optional[bool] = None, diff --git a/lib/coloraide/spaces/srgb_linear.py b/lib/coloraide/spaces/srgb_linear.py index a465847e..b8b668f5 100644 --- a/lib/coloraide/spaces/srgb_linear.py +++ b/lib/coloraide/spaces/srgb_linear.py @@ -3,7 +3,6 @@ from .srgb import SRGB from .. import algebra as alg from ..types import Vector -from typing import cast RGB_TO_XYZ = [ @@ -26,13 +25,13 @@ def lin_srgb_to_xyz(rgb: Vector) -> Vector: D65 (no chromatic adaptation) """ - return cast(Vector, alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1)) + return alg.dot(RGB_TO_XYZ, rgb, dims=alg.D2_D1) def xyz_to_lin_srgb(xyz: Vector) -> Vector: """Convert XYZ to linear-light sRGB.""" - return cast(Vector, alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1)) + return alg.dot(XYZ_TO_RGB, xyz, dims=alg.D2_D1) class SRGBLinear(SRGB): diff --git a/lib/coloraide/spaces/xyy.py b/lib/coloraide/spaces/xyy.py new file mode 100644 index 00000000..da8ab95d --- /dev/null +++ b/lib/coloraide/spaces/xyy.py @@ -0,0 +1,37 @@ +""" +The xyY color space. + +https://en.wikipedia.org/wiki/CIE_1931_color_space#CIE_xy_chromaticity_diagram_and_the_CIE_xyY_color_space +""" +from ..spaces import Space +from ..channels import Channel +from ..cat import WHITES +from .. import util +from ..types import Vector +from typing import Tuple + + +class XyY(Space): + """The xyY class.""" + + BASE = "xyz-d65" + NAME = "xyy" + SERIALIZE = ("--xyy",) # type: Tuple[str, ...] + CHANNELS = ( + Channel("x", 0.0, 1.0), + Channel("y", 0.0, 1.0), + Channel("Y", 0.0, 1.0) + ) + WHITE = WHITES['2deg']['D65'] + + @classmethod + def to_base(cls, coords: Vector) -> Vector: + """To XYZ.""" + + return util.xy_to_xyz(coords[0:2], coords[2]) + + @classmethod + def from_base(cls, coords: Vector) -> Vector: + """From XYZ.""" + + return util.xyz_to_xyY(coords, cls.white()) diff --git a/lib/coloraide/spaces/xyz_d65.py b/lib/coloraide/spaces/xyz_d65.py index e716f686..83cb2395 100644 --- a/lib/coloraide/spaces/xyz_d65.py +++ b/lib/coloraide/spaces/xyz_d65.py @@ -1,7 +1,7 @@ """XYZ D65 class.""" from ..spaces import Space from ..cat import WHITES -from ..gamut.bounds import GamutUnbound +from ..channels import Channel from ..types import Vector from typing import Tuple @@ -12,50 +12,12 @@ class XYZD65(Space): BASE = "xyz-d65" NAME = "xyz-d65" SERIALIZE = ("xyz-d65", 'xyz') # type: Tuple[str, ...] - CHANNEL_NAMES = ("x", "y", "z") - WHITE = WHITES['2deg']['D65'] - - BOUNDS = ( - GamutUnbound(0.0, 1.0), - GamutUnbound(0.0, 1.0), - GamutUnbound(0.0, 1.0) + CHANNELS = ( + Channel("x", 0.0, 1.0), + Channel("y", 0.0, 1.0), + Channel("z", 0.0, 1.0) ) - - @property - def x(self) -> float: - """X channel.""" - - return self._coords[0] - - @x.setter - def x(self, value: float) -> None: - """Shift the X.""" - - self._coords[0] = value - - @property - def y(self) -> float: - """Y channel.""" - - return self._coords[1] - - @y.setter - def y(self, value: float) -> None: - """Set Y.""" - - self._coords[1] = value - - @property - def z(self) -> float: - """Z channel.""" - - return self._coords[2] - - @z.setter - def z(self, value: float) -> None: - """Set Z channel.""" - - self._coords[2] = value + WHITE = WHITES['2deg']['D65'] @classmethod def to_base(cls, coords: Vector) -> Vector: diff --git a/lib/coloraide/types.py b/lib/coloraide/types.py index 7a02cf77..2b504894 100644 --- a/lib/coloraide/types.py +++ b/lib/coloraide/types.py @@ -15,3 +15,13 @@ ArrayLike = Union[VectorLike, MatrixLike] # For times when we must explicitly say we support `int` and `float` SupportsFloatOrInt = TypeVar('SupportsFloatOrInt', float, int) + + +class Plugin: + """ + Plugin type base class. + + A common class used to help simplify typing in some cases. + """ + + NAME = "" diff --git a/lib/coloraide/util.py b/lib/coloraide/util.py index c8525dd9..c1d40aa9 100644 --- a/lib/coloraide/util.py +++ b/lib/coloraide/util.py @@ -6,7 +6,6 @@ from .types import Vector, VectorLike from typing import Any, Callable -ACHROMATIC_THRESHOLD = 0.0005 DEF_PREC = 5 DEF_FIT_TOLERANCE = 0.000075 DEF_ALPHA = 1.0 @@ -14,6 +13,7 @@ DEF_HUE_ADJ = "shorter" DEF_INTERPOLATE = "oklab" DEF_FIT = "lch-chroma" +DEF_HARMONY = "oklch" DEF_DELTA_E = "76" ERR_MAP_MSG = """ @@ -42,11 +42,18 @@ class MyNewClass(Color): C3 = 2392 / 128 -def xy_to_xyz(xy: VectorLike, Y: float = 1) -> Vector: - """Convert `xyY` to `xyz`.""" +def xy_to_xyz(xy: VectorLike, Y: float = 1.0, scale: float = 1.0) -> Vector: + """ + Convert `xyY` to `xyz`. + + In many cases, we are dealing with chromaticity values with no Y value, + in this case, assume 1 unless otherwise specified. Generally, scale is + also assumed to be between 0 - 1, but allow changing scale if we are + dealing with things like 0 - 100, etc. + """ x, y = xy - return [0, 0, 0] if y == 0 else [(x * Y) / y, Y, (1 - x - y) * Y / y] + return [0, 0, 0] if y == 0 else [(x * Y) / y, Y, (scale - x - y) * Y / y] def xy_to_uv(xy: VectorLike) -> Vector: @@ -137,16 +144,16 @@ def pq_st2084_eotf( return adjusted -def xyz_d65_to_absxyzd65(xyzd65: VectorLike) -> Vector: +def xyz_d65_to_absxyzd65(xyzd65: VectorLike, yw: float = YW) -> Vector: """XYZ D65 to Absolute XYZ D65.""" - return [max(c * YW, 0) for c in xyzd65] + return [max(c * yw, 0) for c in xyzd65] -def absxyzd65_to_xyz_d65(absxyzd65: VectorLike) -> Vector: +def absxyzd65_to_xyz_d65(absxyzd65: VectorLike, yw: float = YW) -> Vector: """Absolute XYZ D65 XYZ D65.""" - return [max(c / YW, 0) for c in absxyzd65] + return [max(c / yw, 0) for c in absxyzd65] def constrain_hue(hue: float) -> float: @@ -164,7 +171,7 @@ def cmp_coords(c1: VectorLike, c2: VectorLike) -> bool: return all(map(lambda a, b: (math.isnan(a) and math.isnan(b)) or a == b, c1, c2)) -def fmt_float(f: float, p: int = 0, percent: float = 0.0) -> str: +def fmt_float(f: float, p: int = 0, percent: float = 0.0, offset: float = 0.0) -> str: """ Set float precision and trim precision zeros. @@ -176,7 +183,7 @@ def fmt_float(f: float, p: int = 0, percent: float = 0.0) -> str: if alg.is_nan(f): return "none" - value = alg.round_to(f / (percent * 0.01) if percent else f, p) + value = alg.round_to((f + offset) / (percent * 0.01) if percent else f, p) string = ('{{:{}f}}'.format('.53' if p == -1 else '.' + str(p))).format(value) s = string if value.is_integer() and p == 0 else string.rstrip('0').rstrip('.') return '{}%'.format(s) if percent else s diff --git a/lib/colorbox.py b/lib/colorbox.py index 6bab208b..39b3793f 100644 --- a/lib/colorbox.py +++ b/lib/colorbox.py @@ -45,12 +45,8 @@ def to_list(rgb, alpha=False): and convert to a list with format `[r, g, b]`. """ - r, g, b = [process_channel(c) for c in rgb.coords()] - if alpha: - a = process_channel(rgb.alpha) - return [r, g, b, a] - else: - return [r, g, b] + r, g, b, a = [process_channel(c) for c in rgb[:]] + return [r, g, b, a] if alpha else [r, g, b] def checkered_color(color, background): diff --git a/messages.json b/messages.json index fd6b29c7..dfaf9397 100644 --- a/messages.json +++ b/messages.json @@ -1,4 +1,4 @@ { "install": "messages/install.md", - "4.3.0": "messages/recent.md" + "5.0.0": "messages/recent.md" } diff --git a/messages/recent.md b/messages/recent.md index ead41b00..41a27a6a 100644 --- a/messages/recent.md +++ b/messages/recent.md @@ -1,16 +1,31 @@ -# ColorHelper 4.3.0 +# ColorHelper 5.0.0 New release! See `Preferences->Package Settings->ColorHelper->Changelog` for more info on prior releases. -Restart of Sublime Text may be required. +A restart of Sublime Text is **strongly** encouraged. -## 4.3.0 +# 5.0.0 -- **NEW**: Upgrade `coloraide`, along with various improvements brings - the new HSLuv color space. -- **NEW**: New `coloraide` enforces the old Lch gamut mapping as some - issues with the CSS recommended Oklch were discovered. -- **NEW**: Add HSLuv based color picker. Can be enabled in the settings. +> **BREAKING CHANGE**: Newest `coloraide` was updated. It is approaching +> a 1.0 release. In the path to a 1.0 release some refactoring and +> reworking caused custom color classes to break. All internal color +> classes should be fine, but any users that created custom local +> color classes will need to update the color classes and color spaces +> to work with the latest version. + +- **NEW**: Upgrade to latest `coloraide`. +- **NEW**: Many new color spaces have been added and can optionally + be included via the new `add_to_default_spaces` option. Some that + were available previously are no longer registered by default. + See `add_to_default_spaces` in the settings file to enable more + spaces. A restart of Sublime Text is required when changing this + setting. +- **NEW**: Add new `add_to_default_spaces` which allows a user to add + NEW color spaces to the default color space so that the new spaces + can be saved and recognized in palettes and other areas of ColorHelper. + Modifying this setting requires a restart of Sublime Text. Custom + color classes should only be used to modifying previously added + color spaces to add to recognized input and output formats. diff --git a/support.py b/support.py index ab55b085..b38a7a87 100644 --- a/support.py +++ b/support.py @@ -5,7 +5,7 @@ import webbrowser import re -__version__ = "4.3.1" +__version__ = "5.0.0" __pc_name__ = 'ColorHelper' CSS = ''' diff --git a/tox.ini b/tox.ini index bf41b9a6..064df97b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,5 @@ [tox] +isolated_build = true skipsdist=True envlist = py35,py36,py37,py38,py39,lint @@ -29,4 +30,4 @@ commands= [flake8] ignore=D202,D203,D401,W504,E741,N818 max-line-length=140 -exclude=site/*.py,.tox/*,lib/coloraide/* +exclude=site/*.py,.tox/*,lib/coloraide/*,lib/coloraide_extras