Skip to content

Commit

Permalink
Merge branch 'upstream_master' into dev
Browse files Browse the repository at this point in the history
# Conflicts:
#	LICENSE
#	README.md
#	mkdocs.yml
  • Loading branch information
CodeByZach committed Feb 24, 2022
2 parents fdc5c35 + d36a048 commit 5ec6eaa
Show file tree
Hide file tree
Showing 18 changed files with 141 additions and 111 deletions.
14 changes: 14 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# ColorHelper

## 4.2.0

- **NEW**: Upgrade `coloraide` library which brings back color mapping
mapping in CIELCH. While interpolation is great in Oklab/Oklch, gamut
mapping with chroma reduction in Oklch has some less desirable corner
cases. Using Oklch is in the early stages in the CSS Color Level 4 spec
and there needs to be more time for this to mature and be tested more.
- **NEW**: `oklab()` and `oklch()` CSS format is now available. This form
is based on the published CSS Level 4 spec and requires lightness to be
a percentage. In the future, it is likely that percentages will be
optional for lightness and could even be applied to some of the other
components, but currently, such changes are in some early drafts and
not currently included in ColorHelper.

## 4.1.1

- **FIX**: Fix palette update logic that would not properly format the
Expand Down
15 changes: 9 additions & 6 deletions ColorHelper.sublime-settings
Original file line number Diff line number Diff line change
Expand Up @@ -166,22 +166,25 @@
"css-level-4": {
"filters": [
"srgb", "hsl", "hwb", "lch", "lab", "display-p3", "rec2020",
"prophoto-rgb", "a98-rgb", "xyz-d65", "xyz-d50", "srgb-linear"
"prophoto-rgb", "a98-rgb", "xyz-d65", "xyz-d50", "srgb-linear",
"oklab", "oklch"
],
"output": [
{"space": "srgb", "format": {"hex": true}},
{"space": "srgb", "format": {"comma": true}},
{"space": "hsl", "format": {"comma": true}},
{"space": "hwb", "format": {"comma": false}},
{"space": "lch", "format": {"comma": false}},
{"space": "lab", "format": {"comma": false}},
{"space": "a98-rgb", "format": {}},
{"space": "display-p3", "format": {}},
{"space": "rec2020", "format": {}},
{"space": "prophoto-rgb", "format": {}},
{"space": "a98-rgb", "format": {}},
{"space": "rec2020", "format": {}},
{"space": "srgb-linear", "format": {}},
{"space": "xyz-d65", "format": {}},
{"space": "xyz-d50", "format": {}}
{"space": "xyz-d50", "format": {}},
{"space": "lab", "format": {"comma": false}},
{"space": "lch", "format": {"comma": false}},
{"space": "oklab", "format": {"comma": false}},
{"space": "oklch", "format": {"comma": false}}
]
},
"css-level-3": {
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright (c) 2015 - 2021 Isaac Muse <[email protected]>
Copyright (c) 2015 - 2022 Isaac Muse

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
Expand Down
9 changes: 7 additions & 2 deletions ch_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@
COLOR_FMT_2_0 = (0, 3, 0, 'final')
PALETTE_FMT = (2, 0)

RE_COLOR_START = \
r"(?i)(?:\b(?<![-#&$])(?:color\((?!\s*-)|(?:hsla?|lch|lab|hwb|rgba?)\()|\b(?<![-#&$])[\w]{3,}(?![(-])\b|(?<![&])#)"
RE_COLOR_START = r"""(?xi)
(?:
\b(?<![-#&$])(?:
color\((?!\s*-)|(?:hsla?|(?:ok)?(?:lch|lab)|hwb|rgba?)\(
) |
\b(?<![-#&$])[\w]{3,}(?![(-])\b|(?<![&])\#)
"""

COLOR = {"color": True, "fit": False}
HEX = {"hex": True}
Expand Down
2 changes: 1 addition & 1 deletion docs/src/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
mkdocs_pymdownx_material_extras>=1.5.7
mkdocs_pymdownx_material_extras>=1.6
mkdocs-git-revision-date-localized-plugin
mkdocs-minify-plugin
pyspelling
2 changes: 1 addition & 1 deletion lib/coloraide/__meta__.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,5 @@ def parse_version(ver: str) -> Version:
return Version(major, minor, micro, release, pre, post, dev)


__version_info__ = Version(0, 8, 0, "final")
__version_info__ = Version(0, 10, 0, "final")
__version__ = __version_info__._get_canonical()
22 changes: 10 additions & 12 deletions lib/coloraide/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
from .spaces.rec2020 import Rec2020
from .spaces.xyz_d65 import XYZD65
from .spaces.xyz_d50 import XYZD50
from .spaces.oklab import Oklab
from .spaces.oklch import Oklch
from .spaces.oklab.css import Oklab
from .spaces.oklch.css import Oklch
from .spaces.jzazbz import Jzazbz
from .spaces.jzczhz import JzCzhz
from .spaces.ictcp import ICtCp
Expand Down Expand Up @@ -107,6 +107,7 @@ class Color(metaclass=BaseColor):
FIT_MAP = {} # type: Dict[str, Type[Fit]]
PRECISION = util.DEF_PREC
FIT = util.DEF_FIT
INTERPOLATE = util.DEF_INTERPOLATE
DELTA_E = util.DEF_DELTA_E
CHROMATIC_ADAPTATION = 'bradford'

Expand Down Expand Up @@ -375,11 +376,7 @@ def new(
filters: Optional[Sequence[str]] = None,
**kwargs: Any
) -> 'Color':
"""
Create new color object.
TODO: maybe allow `currentcolor` here? It would basically clone the current object.
"""
"""Create new color object."""

return type(self)(color, data, alpha, filters=filters, **kwargs)

Expand Down Expand Up @@ -496,7 +493,7 @@ def clip(self, space: Optional[str] = None, *, in_place: bool = False) -> 'Color
name = c._space.hue_name()
c.set(name, util.constrain_hue(c.get(name)))
else:
c._space._coords = gamut.clip_channels(c)
gamut.clip_channels(c)
c.normalize()

# Adjust "this" color
Expand Down Expand Up @@ -542,7 +539,7 @@ def fit(
c.set(name, util.constrain_hue(c.get(name)))
else:
# Doesn't seem to be an easy way that `mypy` can know whether this is the ABC class or not
c._space._coords = func(c, **kwargs)
func(c, **kwargs)
c.normalize()

# Adjust "this" color
Expand Down Expand Up @@ -588,6 +585,7 @@ def steps(
steps: int = 2,
max_steps: int = 1000,
max_delta_e: float = 0,
delta_e: Optional[str] = None,
**interpolate_args: Any
) -> List['Color']:
"""
Expand All @@ -602,7 +600,7 @@ def steps(
Default delta E method used is delta E 76.
"""

return self.interpolate(color, **interpolate_args).steps(steps, max_steps, max_delta_e)
return self.interpolate(color, **interpolate_args).steps(steps, max_steps, max_delta_e, delta_e)

def mix(
self,
Expand All @@ -628,7 +626,7 @@ def interpolate(
self,
color: Union[Union[ColorInput, interpolate.Piecewise], Sequence[Union[ColorInput, interpolate.Piecewise]]],
*,
space: str = "lab",
space: Optional[str] = None,
out_space: Optional[str] = None,
stop: float = 0,
progress: Optional[Callable[..., float]] = None,
Expand All @@ -647,7 +645,7 @@ def interpolate(
mixing occurs.
"""

space = space.lower()
space = (space if space is not None else self.INTERPOLATE).lower()
out_space = self.space() if out_space is None else out_space.lower()

# A piecewise object was provided, so treat it as such,
Expand Down
7 changes: 3 additions & 4 deletions lib/coloraide/gamut/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Gamut handling."""
from .. import util
from ..util import MutableVector
from ..spaces import FLG_ANGLE, GamutBound
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING, Optional, Any
Expand All @@ -9,7 +8,7 @@
from ..color import Color


def clip_channels(color: 'Color') -> MutableVector:
def clip_channels(color: 'Color') -> None:
"""Clip channels."""

channels = util.no_nans(color.coords())
Expand All @@ -34,7 +33,7 @@ def clip_channels(color: 'Color') -> MutableVector:

# Fit value in bounds.
fit.append(util.clamp(value, a, b))
return fit
color.update(color.space(), fit, color.alpha)


def verify(color: 'Color', tolerance: float) -> bool:
Expand Down Expand Up @@ -68,5 +67,5 @@ class Fit(ABCMeta):

@classmethod
@abstractmethod
def fit(cls, color: 'Color', **kwargs: Any) -> MutableVector:
def fit(cls, color: 'Color', **kwargs: Any) -> None:
"""Get coordinates of the new gamut mapped color."""
1 change: 1 addition & 0 deletions lib/coloraide/gamut/fit_lch_chroma.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ class LchChroma(OklchChroma):
DE = "2000"
SPACE = "lch"
SPACE_COORDINATE = "{}.chroma".format(SPACE)
MAX_LIGHTNESS = 100
87 changes: 37 additions & 50 deletions lib/coloraide/gamut/fit_oklch_chroma.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Fit by compressing chroma in Oklch."""
from ..gamut import Fit
from ..util import MutableVector
from ..gamut import Fit, clip_channels
from ..util import NaN
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING: # pragma: no cover
Expand All @@ -17,67 +17,54 @@ class OklchChroma(Fit):
DE = "ok"
SPACE = "oklch"
SPACE_COORDINATE = "{}.chroma".format(SPACE)
MIN_LIGHTNESS = 0
MAX_LIGHTNESS = 1

@classmethod
def fit(cls, color: 'Color', **kwargs: Any) -> MutableVector:
def fit(cls, color: 'Color', **kwargs: Any) -> None:
"""
Gamut mapping via Oklch chroma.
Algorithm originally came from https://colorjs.io/docs/gamut-mapping.html.
Some things have been optimized and fixed though to better perform as intended.
Algorithm is not also defined in the CSS specification: https://drafts.csswg.org/css-color/#binsearch.
The idea is to hold hue and lightness constant and decrease chroma until
color comes under gamut.
Some things have been optimized and fixed though to better perform as intended.
We'll use a binary search and at after each stage, we will clip the color
and compare the distance of the two colors (clipped and current color via binary search).
If the distance is less than the `JND`, we will return the color.
Algorithm basically uses a combination of chroma compression which helps to keep hue constant
and color distancing to clip the color with minimal changes to other channels.
The basic idea is preserve lightness and hue as much as possible and find the closest color that
is in gamut.
We do not use the algorithm as defined in the CSS specification: https://drafts.csswg.org/css-color/#binsearch.
This is because the current algorithm, as defined, has some issues that create gradients that are not smooth.
---
Original Authors: Lea Verou, Chris Lilley
License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json)
License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json).
"""

space = color.space()

# If flooring chroma doesn't work, just clip the floored color
# because there is no optimal compression.
floor = color.clone().set(cls.SPACE_COORDINATE, 0)
if not floor.in_gamut(tolerance=0):
return floor.clip().coords()

# If we are already below the JND, just clip as we will gain no
# noticeable difference moving forward.
clipped = color.clip()
if color.delta_e(clipped, method=cls.DE) < cls.LIMIT:
return clipped.coords()

# Convert to CIELCH and set our boundaries
mapcolor = color.convert(cls.SPACE)
low = 0.0
high = mapcolor.chroma

# Adjust chroma (using binary search).
# This helps preserve the other attributes of the color.
# Each time we compare the compressed color to it's clipped form
# to see how close we are. A delta less than 2 is our target.
while (high - low) > cls.EPSILON:
delta = mapcolor.delta_e(
mapcolor.clip(space),
method=cls.DE
)

if (delta - cls.LIMIT) < cls.EPSILON:
low = mapcolor.chroma
else:
high = mapcolor.chroma
# Return white or black if lightness is out of range
lightness = mapcolor.lightness
if lightness >= cls.MAX_LIGHTNESS or lightness <= cls.MIN_LIGHTNESS:
mapcolor.chroma = 0
mapcolor.hue = NaN
clip_channels(color.update(mapcolor))
return

mapcolor.chroma = (high + low) * 0.5

# Update and clip off noise
return color.update(mapcolor).clip(space, in_place=True).coords()
low = 0.0
high = mapcolor.chroma
clip_channels(color.update(mapcolor))

# If we are really close, skip gamut mapping and return the clipped value.
if mapcolor.delta_e(color, method=cls.DE) >= cls.LIMIT:
# Adjust chroma (using binary search).
# This helps preserve the other attributes of the color.
# Each time we compare the compressed color to it's clipped form
# to see how close we are. A delta less than JND is our lower bound
# and a value higher our upper. Continue until bounds converge.
while (high - low) > cls.EPSILON:
if mapcolor.delta_e(color, method=cls.DE) < cls.LIMIT:
low = mapcolor.chroma
else:
high = mapcolor.chroma

mapcolor.chroma = (high + low) * 0.5
clip_channels(color.update(mapcolor))
Loading

0 comments on commit 5ec6eaa

Please sign in to comment.