Skip to content

Commit

Permalink
Fix some issues with averaging (#443)
Browse files Browse the repository at this point in the history
* Fix some issues with averaging

- Apply premultiplication to hues as they are being averaged in
  Cartesian coordinates.
- When a hue becomes undefined during averaging, the color should
  become achromatic.
- Evenly distributed colors should have always produced undefined hues
  due to how circular means work.
- Undefined alpha should apply to premultiplicated channels as zero.
  • Loading branch information
facelessuser authored Dec 5, 2024
1 parent deff898 commit 96a83a0
Show file tree
Hide file tree
Showing 15 changed files with 165 additions and 64 deletions.
2 changes: 1 addition & 1 deletion coloraide/__meta__.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version:
return Version(major, minor, micro, release, pre, post, dev)


__version_info__ = Version(4, 0, 1, "final")
__version_info__ = Version(4, 1, 0, "final")
__version__ = __version_info__._get_canonical()
69 changes: 50 additions & 19 deletions coloraide/average.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,39 @@
"""Average colors together."""
from __future__ import annotations
import math
from .spaces import HWBish
from .types import ColorInput
from typing import Iterable, TYPE_CHECKING

if TYPE_CHECKING: # pragma: no cover
from .color import Color


ACHROMATIC_THRESHOLD = 1e-4


def average(
color_cls: type[Color],
colors: Iterable[ColorInput],
space: str,
premultiplied: bool = True,
powerless: bool = False
premultiplied: bool = True
) -> Color:
"""Average a list of colors together."""
"""
Average a list of colors together.
Polar coordinates use a circular mean: https://en.wikipedia.org/wiki/Circular_mean.
"""

obj = color_cls(space, [])

# Get channel information
cs = obj.CS_MAP[space]
hue_index = cs.hue_index() if cs.is_polar() else -1 # type: ignore[attr-defined]
if cs.is_polar():
hue_index = cs.hue_index() # type: ignore[attr-defined]
is_hwb = isinstance(cs, HWBish)
else:
hue_index = -1
is_hwb = False
channels = cs.channels
chan_count = len(channels)
alpha_index = chan_count - 1
Expand All @@ -35,20 +47,25 @@ def average(
for c in colors:
obj.update(c)
# If cylindrical color is achromatic, ensure hue is undefined
if powerless and hue_index >= 0 and not math.isnan(obj[hue_index]) and obj.is_achromatic():
if hue_index >= 0 and not math.isnan(obj[hue_index]) and obj.is_achromatic():
obj[hue_index] = math.nan
coords = obj[:]
alpha = coords[-1]
if math.isnan(alpha):
alpha = 1.0
i = 0
for coord in coords:
if not math.isnan(coord):
# No need to average an undefined value or color components if alpha is zero
if not math.isnan(coord) and (premultiplied or alpha or i == alpha_index):
totals[i] += 1
if i == hue_index:
rad = math.radians(coord)
sin += math.sin(rad)
cos += math.cos(rad)
if premultiplied:
sin += math.sin(rad) * alpha
cos += math.cos(rad) * alpha
else:
sin += math.sin(rad)
cos += math.cos(rad)
else:
sums[i] += (coord * alpha) if premultiplied and i != alpha_index else coord
i += 1
Expand All @@ -57,21 +74,35 @@ def average(
raise ValueError('At least one color must be provided in order to average colors')

# Get the mean
alpha = sums[-1]
alpha_t = totals[-1]
sums[-1] = math.nan if not alpha_t else alpha / alpha_t
alpha = sums[-1]
if math.isnan(alpha) or alpha in (0.0, 1.0):
sums[-1] = alpha = math.nan if not totals[-1] else (sums[-1] / totals[-1])
if math.isnan(alpha):
alpha = 1.0
for i in range(chan_count - 1):
total = totals[i]
if not total:
if not total or not alpha:
sums[i] = math.nan
elif i == hue_index:
avg_theta = math.degrees(math.atan2(sin / total, cos / total))
sums[i] = (avg_theta + 360) if avg_theta < 0 else avg_theta
if premultiplied:
sin /= total * alpha
cos /= total * alpha
else:
sin /= total
cos /= total
if abs(sin) < ACHROMATIC_THRESHOLD and abs(cos) < ACHROMATIC_THRESHOLD:
sums[i] = math.nan
else:
avg_theta = math.degrees(math.atan2(sin, cos))
sums[i] = (avg_theta + 360) if avg_theta < 0 else avg_theta
else:
sums[i] /= total * alpha if premultiplied else total
sums[i] /= (total * alpha) if premultiplied else total

# Return the color
return obj.update(space, sums[:-1], sums[-1])
# Create the color and if polar and there is no defined hue, force an achromatic state.
color = obj.update(space, sums[:-1], sums[-1])
if cs.is_polar():
if is_hwb and math.isnan(color[hue_index]):
w, b = cs.indexes()[1:] # type: ignore[attr-defined]
if color[w] + color[b] < 1:
color[w] = 1 - color[b]
elif math.isnan(color[hue_index]) and not math.isnan(color[cs.radial_index()]): # type: ignore[attr-defined]
color[cs.radial_index()] = 0 # type: ignore[attr-defined]
return color
8 changes: 5 additions & 3 deletions coloraide/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from . import temperature
from . import util
from . import algebra as alg
from .deprecate import deprecated
from .deprecate import deprecated, warn_deprecated
from itertools import zip_longest as zipl
from .css import parse
from .types import VectorLike, Vector, ColorInput
Expand Down Expand Up @@ -1155,12 +1155,14 @@ def average(
if out_space is None:
out_space = space

if powerless is not None: # pragma: no cover
warn_deprecated("The use of 'powerless' with 'average()' is deprecated as it is now always enabled")

return average.average(
cls,
colors,
space,
premultiplied,
powerless if powerless is not None else cls.POWERLESS,
premultiplied
).convert(out_space, in_place=True)

def filter( # noqa: A003
Expand Down
15 changes: 15 additions & 0 deletions docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 4.1.0

- **NEW**: The `powerless` parameter is deprecated in `average()` as it is required to be always on for proper polar
space averaging.
- **FIX**: Polar space averaging was not setting hues to undefined when colors are evenly distributed. This is
required as circular means cannot be found in these cases.
- **FIX**: When averaging within a polar space, if the result hue is determined to be undefined at the end of
averaging, the color will be treated as if achromatic, setting saturation/chroma as necessary. This is needed to
prevent serialization of achromatic colors to a non-achromatic color when undefined values are resolved.
- **FIX**: Fully transparent colors should only contribute alpha in color averaging, regardless of `premultiply`
setting. This prevents fully transparent color channels, which provide no meaningful data, from distorting averages.
- **FIX**: When averaging in a polar space, if a color is considered achromatic but does not have an undefined hue,
the hue will be treated as undefined. Essentially the `powerless` parameter is now always `True`. This ensures that
achromatic colors properly contribute to the average without distorting the hue.

## 4.0.1

- **FIX**: Fix issue with `continuous` interpolation (and any that are derived from it, e.g., cubic spline
Expand Down
5 changes: 5 additions & 0 deletions docs/src/markdown/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,11 @@ Return

///

/// warning | Deprecated 4.1
`powerless` parameter has been deprecated in 4.1 and as it is now always enabled. The parameter no longer does anything
and will be removed in the future.
///

## `#!py Color.filter` {#cvd}

```py
Expand Down
35 changes: 21 additions & 14 deletions docs/src/markdown/average.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,28 @@ Color.average(['red', 'yellow', 'orange', 'green'])
ColorAide can average colors in rectangular spaces and cylindrical spaces. When applying averaging in a cylindrical
space, hues will be averaged taking the circular mean.

Cylindrical averaging may not provide as good of results as using rectangular spaces, but is provided to provide a sane
approach if a cylindrical space is used.
Colors that appear to be achromatic will have their hue treated as undefined, even if the hue is defined.

Cylindrical averaging may provide very different results that averaging in rectangular spaces.

```py play
Color.average(['orange', 'yellow', 'red'])
Color.average(['orange', 'yellow', 'red'], space='hsl')
Color.average(['purple', 'green', 'blue'])
Color.average(['purple', 'green', 'blue'], space='hsl')
```

Because calculations are done in a cylindrical space, the averaged colors can be different than what is acquired with
rectangular space averaging.
It should be noted that when averaging colors with hues which are evenly distributed around the color wheel, the result
will produce an achromatic hue. When achromatic hues are produced during circular mean, the color will discard
chroma/saturation information, producing an achromatic color.

```py play
Color.average(['purple', 'green', 'blue'])
Color.average(['purple', 'green', 'blue'], space='hsl')
Color.average(['red', 'green', 'blue'], space='hsl')
```

## Averaging with Transparency

ColorAide, by default, will account for transparency when averaging colors. Colors which are more transparent will have
less of an impact on the average. This is done by premultiplying the colors before averaging.
less of an impact on the average. This is done by premultiplying the colors before averaging, essentially weighting the
color components where more opaque colors have a greater influence on the average.

```py play
for i in range(12):
Expand All @@ -57,8 +59,12 @@ for i in range(12):
)
```

If you'd like to average the channels without taking transparency into consideration, simply set `premultiplied` to
`#!py False`.
There are cases where this approach of averaging may not be desired. It may be that color averaging is desired without
considering transparency. If so, `premultiplied` can be disabled by setting it to `#!py False`. While the average of
transparency is calculated, it can be discarded from the final result if desired.

It should be noted that when a color is fully transparent, its color components will be ignored, regardless of the
`premultiplied` parameter, as fully transparent colors provide no meaningful color information.

```py play
for i in range(12):
Expand All @@ -77,8 +83,8 @@ provided for averaging cylindrical colors, particularly achromatic colors.
Color.average(['white', 'color(srgb 0 0 1)'], space='hsl')
```

Implied achromatic hues are only considered undefined if `powerless` is enabled. This is similar to how interpolation
works. By default, explicitly defined hues are respected if working directly in the averaging color space.
When averaging hues in a polar space, implied achromatic hues are also treated as undefined as counting such hues would
distort the average in a non-meaningful way.

```py play
Color.average(['hsl(30 0 100)', 'hsl(240 100 50 / 1)'], space='hsl')
Expand All @@ -94,7 +100,8 @@ for i in range(12):
Color.average(['darkgreen', f'color(srgb 0 none 0 / {i / 11})', 'color(srgb 0 0 1)'])
```

When `premultiplied` is enabled, premultiplication will not be applied to a color if its `alpha` is undefined.
When `premultiplied` is enabled, premultiplication will not be applied to a color if its `alpha` is undefined as it is
unknown how to weight the color, instead the color is treated with full weight.

```py play
Color.average(['darkgreen', f'color(srgb 0 0.50196 0 / none)', 'color(srgb 0 0 1)'])
Expand Down
2 changes: 1 addition & 1 deletion docs/src/markdown/demos/3d_models.html
Original file line number Diff line number Diff line change
Expand Up @@ -909,7 +909,7 @@ <h1>ColorAide Color Space Models</h1>
let colorSpaces = null
let colorGamuts = null
let lastModel = null
let package = 'coloraide-4.0.1-py3-none-any.whl'
let package = 'coloraide-4.1-py3-none-any.whl'
const defaultSpace = 'lab'
const defaultGamut = 'srgb'
const exceptions = new Set(['hwb', 'ryb', 'ryb-biased'])
Expand Down
2 changes: 1 addition & 1 deletion docs/src/markdown/demos/colorpicker.html
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ <h1>ColorAide Color Picker</h1>
let pyodide = null
let webspace = ''
let initial = 'oklab(0.69 0.13 -0.1 / 0.85)'
let package = 'coloraide-4.0.1-py3-none-any.whl'
let package = 'coloraide-4.1-py3-none-any.whl'

const base = `${window.location.origin}/${window.location.pathname.split('/')[1]}/playground/`
package = base + package
Expand Down
2 changes: 1 addition & 1 deletion docs/src/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ extra_css:
- assets/coloraide-extras/extra.css
extra_javascript:
- https://unpkg.com/mermaid@10.6.1/dist/mermaid.min.js
- playground-config-4f0f586c.js
- playground-config-152e791e.js
- https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js
- assets/coloraide-extras/extra-notebook.js

Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
var colorNotebook = {
"playgroundWheels": ['pygments-2.17.2-py3-none-any.whl', 'coloraide-4.0.1-py3-none-any.whl'],
"notebookWheels": ['pyyaml', 'Markdown-3.6-py3-none-any.whl', 'pymdown_extensions-10.10.1-py3-none-any.whl', 'pygments-2.17.2-py3-none-any.whl', 'coloraide-4.0.1-py3-none-any.whl'],
"playgroundWheels": ['pygments-2.17.2-py3-none-any.whl', 'coloraide-4.1-py3-none-any.whl'],
"notebookWheels": ['pyyaml', 'Markdown-3.6-py3-none-any.whl', 'pymdown_extensions-10.10.1-py3-none-any.whl', 'pygments-2.17.2-py3-none-any.whl', 'coloraide-4.1-py3-none-any.whl'],
"defaultPlayground": "import coloraide\ncoloraide.__version__\nColor('red')"
}
4 changes: 2 additions & 2 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,9 @@ extra_css:
- assets/coloraide-extras/extra-696a2734c9.css
extra_javascript:
- https://unpkg.com/mermaid@10.6.1/dist/mermaid.min.js
- playground-config-4f0f586c.js
- playground-config-152e791e.js
- https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js
- assets/coloraide-extras/extra-notebook-BvWUnGuN.js
- assets/coloraide-extras/extra-notebook-lX1Ayavv.js

extra:
social:
Expand Down
Loading

0 comments on commit 96a83a0

Please sign in to comment.