Skip to content

Commit

Permalink
Fix some issues with averaging
Browse files Browse the repository at this point in the history
- 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 committed Dec 4, 2024
1 parent deff898 commit 810117b
Show file tree
Hide file tree
Showing 11 changed files with 69 additions and 34 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, 0, 2, "final")
__version__ = __version_info__._get_canonical()
54 changes: 39 additions & 15 deletions coloraide/average.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Average colors together."""
from __future__ import annotations
import math
from .spaces import HSLish, HSVish, LChish, HWBish
from .types import ColorInput
from typing import Iterable, TYPE_CHECKING

Expand All @@ -15,13 +16,23 @@ def average(
premultiplied: bool = True,
powerless: bool = False
) -> 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(): # type: ignore[attr-defined]
hue_index = cs.hue_index()
has_radial = hasattr(cs, 'radial_index')
is_hwb = not has_radial and isinstance(cs, HWBish)
else:
hue_index = 1
has_radial = is_hwb = False
channels = cs.channels
chan_count = len(channels)
alpha_index = chan_count - 1
Expand All @@ -47,8 +58,8 @@ def average(
totals[i] += 1
if i == hue_index:
rad = math.radians(coord)
sin += math.sin(rad)
cos += math.cos(rad)
sin += (math.sin(rad) * alpha) if premultiplied else math.sin(rad)
cos += (math.cos(rad) * alpha) if premultiplied else math.cos(rad)
else:
sums[i] += (coord * alpha) if premultiplied and i != alpha_index else coord
i += 1
Expand All @@ -57,21 +68,34 @@ 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):
alpha = 1.0
sums[-1] = alpha = math.nan if not alpha_t else (sums[-1] / alpha_t)
for i in range(chan_count - 1):
total = totals[i]
if not total:
if not total or (premultiplied and 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) <= 1e-14 and abs(cos) <= 1e-14:
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 has_radial and math.isnan(color[hue_index]):
color[cs.radial_index()] = 0
elif is_hwb and math.isnan(color[hue_index]):
w, b = cs.indexes()[1:]
if color[w] + color[b] < 1:
color[w] = 1 - color[b]
return color
11 changes: 11 additions & 0 deletions docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 4.0.2

- **FIX**: Fix averaging issues.
- Polar spaces should set hues to undefined when colors are evenly distributed as circular means cannot be found
in these cases.
- When averaging within a polar space, if the hue is determined to be undefined during averaging, the color will
be treated as if achromatic.
- Transparency, when premultiplication is enabled, is now taken into account when processing hues in averaging.
- When premultiplication is enabled and a color has undefined transparency, it will be treated as if fully
transparent.

## 4.0.1

- **FIX**: Fix issue with `continuous` interpolation (and any that are derived from it, e.g., cubic spline
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.0.2-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.0.2-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/[email protected]/dist/mermaid.min.js
- playground-config-4f0f586c.js
- playground-config-1700579e.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.0.2-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.2-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/[email protected]/dist/mermaid.min.js
- playground-config-4f0f586c.js
- playground-config-1700579e.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
16 changes: 8 additions & 8 deletions tests/test_average.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ def test_average_cylindrical_premultiplied(self):
results.append(Color.average(colors, space='hsl').to_string(color=True))
self.assertEqual(
results,
['color(--hsl 150 1 0.34804 / 0.66667)',
'color(--hsl 150 1 0.33725 / 0.75)',
'color(--hsl 150 1 0.32863 / 0.83333)',
'color(--hsl 150 1 0.32157 / 0.91667)',
['color(--hsl 180 1 0.34804 / 0.66667)',
'color(--hsl 169.11 1 0.33725 / 0.75)',
'color(--hsl 160.89 1 0.32863 / 0.83333)',
'color(--hsl 154.72 1 0.32157 / 0.91667)',
'color(--hsl 150 1 0.31569)']
)

Expand Down Expand Up @@ -109,10 +109,10 @@ def test_average_ignore_undefined(self):
results.append(Color.average(colors, space='srgb').to_string(color=True))
self.assertEqual(
results,
['color(srgb 0 0.29412 0.5 / 0.66667)',
'color(srgb 0 0.26144 0.44444 / 0.75)',
'color(srgb 0 0.23529 0.4 / 0.83333)',
'color(srgb 0 0.2139 0.36364 / 0.91667)',
['color(srgb 0 0.19608 0.5 / 0.66667)',
'color(srgb 0 0.19608 0.44444 / 0.75)',
'color(srgb 0 0.19608 0.4 / 0.83333)',
'color(srgb 0 0.19608 0.36364 / 0.91667)',
'color(srgb 0 0.19608 0.33333)']
)

Expand Down

0 comments on commit 810117b

Please sign in to comment.