Skip to content

Commit

Permalink
Add/expose CSS powerless and carryforward behavior (#357)
Browse files Browse the repository at this point in the history
  • Loading branch information
facelessuser authored Aug 23, 2023
1 parent 2410d77 commit 34739c6
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 80 deletions.
6 changes: 6 additions & 0 deletions coloraide/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ class Color(metaclass=ColorMeta):
CHROMATIC_ADAPTATION = util.DEF_CHROMATIC_ADAPTATION
CONTRAST = util.DEF_CONTRAST
CCT = util.DEF_CCT
POWERLESS = False
CARRYFORWARD = False

# It is highly unlikely that a user would ever need to override this, but
# just in case, it is exposed, but undocumented.
Expand Down Expand Up @@ -1028,6 +1030,8 @@ def interpolate(
domain: list[float] | None = None,
method: str = "linear",
padding: float | tuple[float, float] | None = None,
carryforward: bool | None = None,
powerless: bool | None = None,
**kwargs: Any
) -> Interpolator:
"""
Expand All @@ -1054,6 +1058,8 @@ def interpolate(
extrapolate=extrapolate,
domain=domain,
padding=padding,
carryforward=carryforward if carryforward is not None else cls.CARRYFORWARD,
powerless=powerless if powerless is not None else cls.POWERLESS,
**kwargs
)

Expand Down
176 changes: 99 additions & 77 deletions coloraide/interpolate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,75 +631,92 @@ def normalize_hue(
return color2, offset


def carryforward_convert(color: Color, space: str) -> None: # pragma: no cover
def carryforward_convert(color: Color, space: str, hue_index: int, powerless: bool) -> None: # pragma: no cover
"""Carry forward undefined values during conversion."""

cs1 = color._space
cs2 = color.CS_MAP[space]
channels = {'r': False, 'g': False, 'b': False, 'h': False, 'c': False, 'l': False, 'v': False}
carry = []

# Gather undefined channels
if isinstance(cs1, RGBish):
for i, name in zip(cs1.indexes(), ('r', 'g', 'b')):
if math.isnan(color[i]):
channels[name] = True
elif isinstance(cs1, LChish):
for i, name in zip(cs1.indexes(), ('l', 'c', 'h')):
if math.isnan(color[i]):
channels[name] = True
elif isinstance(cs1, Labish):
if math.isnan(color[cs1.indexes()[0]]):
channels['l'] = True
elif isinstance(cs1, HSLish):
for i, name in zip(cs1.indexes(), ('h', 'c', 'l')):
if math.isnan(color[i]):
channels[name] = True
elif isinstance(cs1, HSVish):
for i, name in zip(cs1.indexes(), ('h', 'c', 'v')):
if math.isnan(color[i]):
channels[name] = True
elif isinstance(cs1, Cylindrical):
if math.isnan(color[cs1.hue_index()]):
channels['h'] = True

# Carry alpha forward if undefined
if math.isnan(color[-1]):
carry.append(-1)

# Channels that need to be carried forward
if isinstance(cs2, RGBish):
indexes = cs2.indexes()
for e, name in enumerate(('r', 'g', 'b')):
if channels[name]:
carry.append(indexes[e])
elif isinstance(cs2, Labish):
indexes = cs2.indexes()
if channels['l']:
carry.append(indexes[0])
elif isinstance(cs2, LChish):
indexes = cs2.indexes()
for e, name in enumerate(('l', 'c', 'h')):
if channels[name]:
carry.append(indexes[e])
elif isinstance(cs2, HSLish):
indexes = cs2.indexes()
for e, name in enumerate(('h', 'c', 'l')):
if channels[name]:
carry.append(indexes[e])
elif isinstance(cs2, HSVish):
indexes = cs2.indexes()
for e, name in enumerate(('h', 'c', 'v')):
if channels[name]:
carry.append(indexes[e])
elif isinstance(cs2, Cylindrical):
if channels['h']:
carry.append(cs2.hue_index())
needs_conversion = space != color.space()

# Only look to "carry forward" if we have undefined channels
if needs_conversion and any(math.isnan(c) for c in color): # type: ignore[attr-defined]
cs1 = color._space
cs2 = color.CS_MAP[space]
channels = {
'R': False, 'G': False, 'B': False, 'H': False, 'C': False,
'L': False, 'V': False, 'a': False, 'b': False
}

# Gather undefined channels
if isinstance(cs1, RGBish):
for i, name in zip(cs1.indexes(), ('R', 'G', 'B')):
if math.isnan(color[i]):
channels[name] = True
elif isinstance(cs1, LChish):
for i, name in zip(cs1.indexes(), ('L', 'C', 'H')):
if math.isnan(color[i]):
channels[name] = True
elif isinstance(cs1, Labish):
for i, name in zip(cs1.indexes(), ('L', 'a', 'b')):
if math.isnan(color[i]):
channels[name] = True
elif isinstance(cs1, HSLish):
for i, name in zip(cs1.indexes(), ('H', 'C', 'L')):
if math.isnan(color[i]):
channels[name] = True
elif isinstance(cs1, HSVish):
for i, name in zip(cs1.indexes(), ('H', 'C', 'V')):
if math.isnan(color[i]):
channels[name] = True
elif isinstance(cs1, Cylindrical):
if math.isnan(color[cs1.hue_index()]):
channels['H'] = True

# Carry alpha forward if undefined
if math.isnan(color[-1]):
carry.append(-1)

# Channels that need to be carried forward
if isinstance(cs2, RGBish):
indexes = cs2.indexes()
for e, name in enumerate(('R', 'G', 'B')):
if channels[name]:
carry.append(indexes[e])
elif isinstance(cs2, Labish):
indexes = cs2.indexes()
for e, name in enumerate(('L', 'a', 'b')):
if channels[name]:
carry.append(indexes[e])
elif isinstance(cs2, LChish):
indexes = cs2.indexes()
for e, name in enumerate(('L', 'C', 'H')):
if channels[name]:
carry.append(indexes[e])
elif isinstance(cs2, HSLish):
indexes = cs2.indexes()
for e, name in enumerate(('H', 'C', 'L')):
if channels[name]:
carry.append(indexes[e])
elif isinstance(cs2, HSVish):
indexes = cs2.indexes()
for e, name in enumerate(('H', 'C', 'V')):
if channels[name]:
carry.append(indexes[e])
elif hue_index >= 0:
if channels['H']:
carry.append(cs2.hue_index()) # type: ignore[attr-defined]

# Convert the color space
color.convert(space, in_place=True)
for i in carry:
color[i] = math.nan
if needs_conversion:
color.convert(space, in_place=True)

# Carry the undefined values forward
for i in carry:
color[i] = math.nan

# Normalize hue if cylindrical and achromatic
# Carry forward is not needed as nothing was lost through conversion
elif powerless and hue_index >= 0 and color.is_achromatic():
color[hue_index] = math.nan


def interpolator(
Expand All @@ -714,6 +731,8 @@ def interpolator(
extrapolate: bool,
domain: list[float] | None = None,
padding: float | tuple[float, float] | None = None,
carryforward: bool = False,
powerless: bool = False,
**kwargs: Any
) -> Interpolator:
"""Get desired blend mode."""
Expand Down Expand Up @@ -741,16 +760,18 @@ def interpolator(
out_space = space

# Adjust to space
if space != current.space():
if kwargs.get('_carryforward', False): # pragma: no cover
carryforward_convert(current, space)
else:
current.convert(space, in_place=True)

offset = 0.0
hue_index = current._space.hue_index() if isinstance(current._space, Cylindrical) else -1
cs = current.CS_MAP[space]
is_cyl = isinstance(cs, Cylindrical)
hue_index = cs.hue_index() if is_cyl else -1 # type: ignore[attr-defined]
if carryforward:
carryforward_convert(current, space, hue_index, powerless)
elif space != current.space():
current.convert(space, in_place=True)
elif powerless and is_cyl and current.is_achromatic():
current[hue_index] = math.nan

# Normalize hue
offset = 0.0
norm_coords = current[:]
fallback = None
if hue_index >= 0:
Expand Down Expand Up @@ -780,11 +801,12 @@ def interpolator(
stops[i] = None

# Adjust color to space
if space != color.space():
if kwargs.get('_carryforward', False): # pragma: no cover
carryforward_convert(color, space)
else:
color.convert(space, in_place=True)
if carryforward:
carryforward_convert(color, space, hue_index, powerless)
elif space != color.space():
color.convert(space, in_place=True)
elif powerless and is_cyl and color.is_achromatic():
color[hue_index] = math.nan

# Normalize the hue
norm_coords = color[:]
Expand Down
2 changes: 2 additions & 0 deletions docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
- **NEW**: Add `HWBish` mixin class.
- **NEW**: Deprecate `algebra.no_nan()`, `algebra.no_nans()`, and `algebra.is_nan()`.
- **NEW**: When averaging in a cylindrical space, always treat achromatic hues as powerless for better results.
- **NEW**: Add experimental support for CSS "powerless" hue handling and carrying-forward in interpolation, both
disabled by default.
- **FIX**: Fix RLAB conversion.
- **FIX**: Fix clipping of hues.
- **ENHANCE**: Tweaks to some matrix calculations.
Expand Down
6 changes: 6 additions & 0 deletions docs/src/markdown/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,9 @@ def interpolate(
extrapolate: bool = False,
domain: list[float] | None = None,
method: str = "linear",
padding: float | tuple[float, float] | None = None,
carryforward: bool = False,
powerless: bool = False,
**kwargs: Any
) -> Interpolator:
...
Expand Down Expand Up @@ -865,6 +868,9 @@ Parameters
`extrapolate` | `#!py False` | Interpolations should extrapolate when values exceed the domain range ([0, 1] by default).
`domain` | `#!py None` | A list of numbers defining the domain range of the interpolation.
`method` | `#!py "linear"` | The interpolation method to use.
`padding` | `#!py None` | Adjust the padding of the interpolation range.
`carryforward` | `#!py False` | Carry forward undefined channels when converting to the interpolation space.
`powerless` | `#!py False` | Treat explicitly defined hues as powerless when the color is considered achromatic.

Return

Expand Down
2 changes: 2 additions & 0 deletions docs/src/markdown/color.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ Properties | Defaults | Description
`CONTRAST` | `#!py "wcag21"` | Default contrast algorithm.
`AVERAGE` | `#!py "average"` | Default color space for averaging.
`CCT` | `#!py "robertson-1968` | Default CCT method.
`POWERLESS` | `#!py False` | Experimental option that controls default powerless interpolation behavior. If `#!py True`, if a color is considered achromatic, but has an explicit hue, it will be treated as powerless during interpolation.
`CARRYFORWARD` | `#!py False` | Experimental option that controls default carrying-forward behavior during interpolation. If `#!py True`, supported, undefined values will be carried over to the interpolating color space after conversion.

### Plugins

Expand Down
102 changes: 102 additions & 0 deletions docs/src/markdown/interpolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -956,3 +956,105 @@ color.mix(color2, space="hsl")
Technically, any channel can be set to `NaN`. And there are various ways to do this. The
[Color Manipulation documentation](./manipulation.md#undefined-values) goes into the details of how these `Nan` values
naturally occur and the various ways a user and manipulate them.

## Carrying-Forward

/// warning | Experimental
This feature is provided to give parity with CSS behavior. As the spec is still in flux, behavior is subject to change
or feature could be removed entirely. Use at your own risk.
///

CSS introduces the concept of carrying-forward undefined channels of like color spaces during conversion to the
interpolating color space. The idea is to provide a sane handling to users who specified undefined channels for
interpolation, but did not account for the conversion to the interpolating color space.

If a color has undefined channels, and is converting to a like color space, after conversion the new color will have the
same undefined channels, assuming the channels support carrying-forward. The example below demonstrates the concept.

```py play
rgb = Color('srgb', [0.5, NaN, 0.8])
p3 = rgb.convert('display-p3').set('green', NaN)
rgb, p3
```

ColorAide, by default, expects the user to be aware that undefined values are lost if conversion is required for
interpolation. This is mainly because the intent of the color can be changed during this process, but some users may
find the automatic carrying-forward more convenient. For this reason, ColorAide has implemented carrying-forward as an
optional feature via the `carryforward` option.

In this example, interpolating without carrying-forward results in an interpolation between a purplish color and white.
Using carrying-forward, we get a purplish color with an undefined green channel. The green channel takes on the white's
green channel giving us an interpolation between a more greenish color and white.

```py play
Color.interpolate(['color(srgb 0.5 none 0.8)', 'white'], space='display-p3')
Color.interpolate(['color(srgb 0.5 none 0.8)', 'white'], space='display-p3', carryforward=True)
```

Depending on the color space, carrying-forward may have better or worse results.

Channel components supported for `carryforward`. Spaces may use different names for their channels, but if they are
derived from the related space classes, their channels are supported. For instance, `xyz` is derived from `RGBish`,
so `x`, `y`, and `z` is treated like super saturated `r`, `g`, and `b`.

Space\ Type | Channel\ Equivalents
------------ | --------
`RGBish` | `r`, `g`, `b`
`LABish` | `l`
`LCHish` | `l`, `c`, `h`
`HSLish` | `h`, `s`, `l`
`HSVish` | `h`, `s`, `v`
`Clyindrical`| `h`

Carrying-forward is applied within categories.

Category | Components
------------ | ----------
Reds | `r`
Greens | `g`
Blues | `b`
Lightness | `l`
Colorfulness | `c`, `s`
Hue | `h`
Opponent a | `a`
Opponent b | `b`

## Powerless Hues

/// warning | Experimental
This feature is provided to give parity with CSS behavior. As the spec is still in flux, behavior is subject to change
or feature could be removed entirely. Use at your own risk.
///

Normally, ColorAide respects the user's explicitly defined hues. This gives the user power to do things like masking
off all channels but the hue to interpolate only the hue.

```py play
Color.interpolate(['oklch(none none 0)', 'oklch(0.75 0.2 360)'], space='oklch', hue='specified')
```

But when doing this, a user must explicitly define the hue as achromatic if they want the hue to be ignored. Conversions
of achromatic colors to a cylindrical space will, in most cases, have the hue automatically set to undefined.

```py play
Color.interpolate(['oklch(1 0 0)', 'oklch(0.75 0.2 180)'], space='oklch')
Color.interpolate(['oklch(1 0 None)', 'oklch(0.75 0.2 180)'], space='oklch')
```

CSS has the concept of powerless hues which causes explicitly defined hues to be powerless (or act as
[undefined](#null-handling)) when a color is considered achromatic. This means a user never has to think about
achromatic hues, so even if the erroneously define a hue, they will automatically be treated as undefined when
interpolating. ColorAide implements this behavior via the `powerless` option.

```py play
Color.interpolate(['oklch(1 0 0)', 'oklch(0.75 0.2 180)'], space='oklch', powerless=True)
Color.interpolate(['oklch(1 0 None)', 'oklch(0.75 0.2 180)'], space='oklch', powerless=True)
```

The one downside is that control over the hue will be diminished to some degree as ColorAide will no longer respect
a user's explicit hue if the color is determined to be achromatic.

```py play
Color.interpolate(['oklch(none none 0)', 'oklch(0.75 0.2 360)'], space='oklch', hue='specified')
Color.interpolate(['oklch(none none 0)', 'oklch(0.75 0.2 360)'], space='oklch', hue='specified', powerless=True)
```
Loading

0 comments on commit 34739c6

Please sign in to comment.