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