Skip to content

Commit 4b7a6fc

Browse files
authored
Feature/weighted avg (#476)
* Weighted average * Use a rolling average * When no weights are provided, assume all are 1 But fail if color count does not match weight count. * Tweaks to plot scripts * Update tests and documentation
1 parent bafd842 commit 4b7a6fc

9 files changed

Lines changed: 356 additions & 55 deletions

File tree

coloraide/average.py

Lines changed: 74 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,27 @@
22
from __future__ import annotations
33
import math
44
from . import util
5+
import itertools as it
56
from .spaces import HWBish
67
from .types import ColorInput, AnyColor
78
from typing import Iterable
89

10+
11+
class Sentinel(float):
12+
"""Sentinel object that is specific to averaging that we shouldn't see defined anywhere else."""
13+
14+
15+
def _iter_colors(colors: Iterable[ColorInput]) -> Iterable[tuple[ColorInput, float]]:
16+
"""Iterate colors and return weights."""
17+
18+
for c in colors:
19+
yield c, 1.0
20+
21+
922
def average(
1023
color_cls: type[AnyColor],
1124
colors: Iterable[ColorInput],
25+
weights: Iterable[float] | None,
1226
space: str,
1327
premultiplied: bool = True
1428
) -> AnyColor:
@@ -18,6 +32,7 @@ def average(
1832
Polar coordinates use a circular mean: https://en.wikipedia.org/wiki/Circular_mean.
1933
"""
2034

35+
sentinel = Sentinel()
2136
obj = color_cls(space, [])
2237

2338
# Get channel information
@@ -31,67 +46,98 @@ def average(
3146
channels = cs.channels
3247
chan_count = len(channels)
3348
alpha_index = chan_count - 1
34-
sums = [0.0] * chan_count
35-
totals = [0.0] * chan_count
49+
avgs = [0.0] * chan_count
50+
counts = [0] * chan_count
3651
sin = 0.0
3752
cos = 0.0
53+
wavg = 0.0
54+
no_weights = weights is None
55+
if no_weights:
56+
weights = ()
57+
mx = 0.0
3858

39-
# Sum channel values
40-
i = -1
41-
for c in colors:
42-
obj.update(c)
59+
# Sum channel values using a rolling average. Apply premultiplication and additional weighting as required.
60+
count = 0
61+
for c, w in (_iter_colors(colors) if no_weights else it.zip_longest(colors, weights, fillvalue=sentinel)): # type: ignore[arg-type]
62+
63+
# Handle explicit weighted cases
64+
if not no_weights:
65+
# If there are more weights than colors, ignore additional weights
66+
if c is sentinel:
67+
break
68+
69+
# If there are less weights than colors, assume full weight for colors without weights
70+
if w is sentinel:
71+
w = mx
72+
73+
# Negative weights are considered as zero weight
74+
if w < 0.0:
75+
w = 0.0
76+
77+
# Track the largest weight so we can populate colors with no weights
78+
elif w > mx:
79+
mx = w
80+
81+
obj.update(c) # type: ignore[arg-type]
4382
# If cylindrical color is achromatic, ensure hue is undefined
4483
if hue_index >= 0 and not math.isnan(obj[hue_index]) and obj.is_achromatic():
4584
obj[hue_index] = math.nan
4685
coords = obj[:]
4786
alpha = coords[-1]
4887
if math.isnan(alpha):
4988
alpha = 1.0
89+
walpha = alpha * w
90+
count += 1
91+
wavg += (w - wavg) / count
5092
i = 0
5193
for coord in coords:
5294
# No need to average an undefined value or color components if alpha is zero
53-
if not math.isnan(coord) and (premultiplied or alpha or i == alpha_index):
54-
totals[i] += 1
95+
is_alpha = i == alpha_index
96+
if not math.isnan(coord) and (premultiplied or alpha or is_alpha):
97+
counts[i] += 1
98+
n = counts[i]
5599
if i == hue_index:
56100
rad = math.radians(coord)
57101
if premultiplied:
58-
sin += math.sin(rad) * alpha
59-
cos += math.cos(rad) * alpha
102+
sin += ((math.sin(rad) * walpha) - sin) / n
103+
cos += ((math.cos(rad) * walpha) - cos) / n
60104
else:
61-
sin += math.sin(rad)
62-
cos += math.cos(rad)
105+
sin += ((math.sin(rad) * w) - sin) / n
106+
cos += ((math.cos(rad) * w) - cos) / n
63107
else:
64-
sums[i] += (coord * alpha) if premultiplied and i != alpha_index else coord
108+
avgs[i] += (((coord * walpha) if premultiplied and not is_alpha else (coord * w)) - avgs[i]) / n
65109
i += 1
66110

67-
if i == -1:
111+
if not count:
68112
raise ValueError('At least one color must be provided in order to average colors')
69113

70-
# Get the mean
71-
sums[-1] = alpha = math.nan if not totals[-1] else (sums[-1] / totals[-1])
114+
# Undo premultiplication and weighting to get the final color
115+
w_factor = math.nan if not wavg else wavg
116+
avgs[-1] = alpha = math.nan if not counts[-1] else avgs[-1] / w_factor
72117
if math.isnan(alpha):
73118
alpha = 1.0
119+
walpha = alpha * w_factor
120+
74121
for i in range(chan_count - 1):
75-
total = totals[i]
76-
if not total or not alpha:
77-
sums[i] = math.nan
122+
if not counts[i] or not alpha:
123+
avgs[i] = math.nan
78124
elif i == hue_index:
79125
if premultiplied:
80-
sin /= total * alpha
81-
cos /= total * alpha
126+
sin /= walpha
127+
cos /= walpha
82128
else:
83-
sin /= total
84-
cos /= total
129+
sin /= w_factor
130+
cos /= w_factor
85131
if abs(sin) < util.ACHROMATIC_THRESHOLD_SM and abs(cos) < util.ACHROMATIC_THRESHOLD_SM:
86-
sums[i] = math.nan
132+
avgs[i] = math.nan
87133
else:
88134
avg_theta = math.degrees(math.atan2(sin, cos))
89-
sums[i] = (avg_theta + 360) if avg_theta < 0 else avg_theta
135+
avgs[i] = (avg_theta + 360) if avg_theta < 0 else avg_theta
90136
else:
91-
sums[i] /= (total * alpha) if premultiplied else total
137+
avgs[i] /= walpha if premultiplied else w_factor
92138

93-
# Create the color and if polar and there is no defined hue, force an achromatic state.
94-
color = obj.update(space, sums[:-1], sums[-1])
139+
# Create the color. If polar and there is no defined hue, force an achromatic state.
140+
color = obj.update(space, avgs[:-1], avgs[-1])
95141
if cs.is_polar():
96142
if is_hwb and math.isnan(color[hue_index]):
97143
w, b = cs.indexes()[1:] # type: ignore[attr-defined]

coloraide/color.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,6 +1145,7 @@ def interpolate(
11451145
def average(
11461146
cls,
11471147
colors: Iterable[ColorInput],
1148+
weights: Iterable[float] | None = None,
11481149
*,
11491150
space: str | None = None,
11501151
out_space: str | None = None,
@@ -1166,6 +1167,7 @@ def average(
11661167
return average.average(
11671168
cls,
11681169
colors,
1170+
weights,
11691171
space,
11701172
premultiplied
11711173
).convert(out_space, in_place=True)

docs/src/markdown/about/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 4.7
44

55
- **NEW**: Officially support Python 3.14.
6+
- **NEW**: `average()` now accepts weights for weighted averaging.
67
- **ENHANCE**: Switch to deploying with PyPI's "Trusted Publisher".
78
- **ENHANCE**: Performance improvements for various algebraic calculations.
89
- **FIX**: Fix some corner cases with some algebraic calculations.

docs/src/markdown/average.md

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,107 @@
11
# Color Averaging
22

3-
Color averaging is the process of calculating an average color from a set of other colors by taking the mean of each
4-
color channel.
3+
Color averaging is the process taking multiple colors and calculating an average color from them, essentially mixing
4+
all the colors together. This involves looking at each color channel of all colors under consideration and averaging
5+
and averaging each channel independently. Additionally, by default, transparency is taken into account by using
6+
premultiplication which weights the colors such that more opaque colors have a greater significance in the mixing vs
7+
more translucent colors.
8+
9+
![Average RGB](images/avg-rgb.png)
10+
511

612
Averaging under ColorAide can take as many colors as desired and will return a color that represents the average. This
7-
is not to be confused with interpolation which employs a different technique, but in certain situations, it can sort of
8-
function like mixing multiple colors.
13+
is not to be confused with interpolation which employs a different technique. One thing that sets it apart from
14+
interpolation is that when performing the operation, the order of the colors does not matter and will yield the same
15+
results even the colors are shuffled.
16+
17+
Averaging can be used as a way to mix multiple colors into one color or simply determine what the overall average color
18+
is from a set of colors. Results are subject to the geometry of the color space in which the average is performed.
919

1020
## Rectangular Space Averaging
1121

12-
ColorAide, by default, averages in rectangular color spaces, the default being Linear sRGB. If desired, other color
13-
spaces can be used, such as perceptually uniform spaces like Oklab.
22+
ColorAide, by default, averages in the rectangular Linear sRGB color spaces. If desired, other color spaces can be used.
23+
Results will vary due to the geometry of the color space being used.
1424

1525
```py play
1626
Color.average(['red', 'blue'])
1727
Color.average(['red', 'blue'], space='srgb')
1828
Color.average(['red', 'blue'], space='oklab')
1929
```
2030

21-
Averaging is not restricted to any certain amount of colors.
31+
Averaging can be applied to any amount of colors.
2232

2333
```py play
2434
Color.average(['red', 'yellow', 'orange', 'green'])
2535
```
2636

2737
## Cylindrical Space Averaging
2838

29-
ColorAide can average colors in rectangular spaces and cylindrical spaces. When applying averaging in a cylindrical
30-
space, hues will be averaged taking the circular mean.
31-
32-
Colors that appear to be achromatic will have their hue treated as undefined, even if the hue is defined.
33-
34-
Cylindrical averaging may provide very different results that averaging in rectangular spaces.
39+
ColorAide can also average colors in cylindrical spaces. When applying averaging in a cylindrical space, hues will be
40+
averaged taking the circular mean. Due the difference in approach, colors can be quite different.
3541

3642
```py play
3743
Color.average(['purple', 'green', 'blue'])
3844
Color.average(['purple', 'green', 'blue'], space='hsl')
3945
```
4046

41-
It should be noted that when averaging colors with hues which are evenly distributed around the color wheel, the result
47+
Colors that are deemed achromatic will have their hue treated as undefined, even if the hue is defined. This is to
48+
ensure that the average color makes sense and isn't tainted by a non-functional hue.
49+
50+
```py play
51+
Color.average(['white', 'blue'], space='hsl')
52+
```
53+
54+
It should be noted that when averaging colors with hues which are evenly distributed around color space, the result
4255
will produce an achromatic hue. When achromatic hues are produced during circular mean, the color will discard
4356
chroma/saturation information, producing an achromatic color.
4457

4558
```py play
4659
Color.average(['red', 'green', 'blue'], space='hsl')
4760
```
4861

62+
## Weighted Averaging
63+
64+
To allow for greater control and nuance of mixing multiple colors, ColorAide allows weights to be defined to adjust how
65+
much a specific color is mixed relative to other colors.
66+
67+
As an example, let's assume we wanted to mix `#!color orange` and `#!color red` but brighten it up with `#!color white`.
68+
More specifically, let's say we want 4 times the amount of white for every 1 part of the other colors. We can simply
69+
specify weights intuitively as `#!py [1, 1, 4]`.
70+
71+
72+
```py play
73+
Color.average(['orange', 'red', 'white'])
74+
Color.average(['orange', 'red', 'white'], [1, 1, 4])
75+
```
76+
77+
![Weighted Average](images/avg-weighted.png)
78+
79+
Regardless of how big or small the numbers are, they are scaled relative to the largest value, so internally,
80+
`#!py [1, 1, 4]` and `#!py [0.25, 0.25, 1]` are essentially the same.
81+
82+
```py play
83+
Color.average(['orange', 'red', 'white'], [1, 1, 4])
84+
Color.average(['orange', 'red', 'white'], [0.25, 0.25, 1])
85+
```
86+
87+
If more weights are provided that there are colors, the only the weights sufficient to satisfy the number of colors
88+
is consumed.
89+
90+
```py play
91+
Color.average(['orange', 'red', 'white'], [1, 1, 4, 2, 1])
92+
```
93+
94+
If more colors are provided than weights, colors without defined weights are assumed to be full weight.
95+
96+
```py play
97+
Color.average(['orange', 'red', 'white'], [0, 1])
98+
```
99+
100+
/// note
101+
It should be noted that negative weights are not allowed and will be clipped to zero, which treats the colors as if it
102+
is not included at all.
103+
///
104+
49105
## Averaging with Transparency
50106

51107
ColorAide, by default, will account for transparency when averaging colors. Colors which are more transparent will have
@@ -59,11 +115,11 @@ for i in range(12):
59115
)
60116
```
61117

62-
There are cases where this approach of averaging may not be desired. It may be that color averaging is desired without
63-
considering transparency. If so, `premultiplied` can be disabled by setting it to `#!py False`. While the average of
64-
transparency is calculated, it can be discarded from the final result if desired.
118+
There are cases where this approach of averaging may not be desired and results are desired without considering
119+
transparency. If so, `premultiplied` can be disabled by setting it to `#!py False`. While the average of transparency is
120+
still calculated, it can be discarded from the final result if desired.
65121

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

69125
```py play
@@ -76,8 +132,10 @@ for i in range(12):
76132

77133
## Averaging with Undefined Values
78134

79-
When averaging with undefined values, ColorAide will not consider the undefined values in the average. This is mainly
80-
provided for averaging cylindrical colors, particularly achromatic colors.
135+
When averaging with undefined values, ColorAide will not consider the undefined values in the average. In short, it
136+
will be treated as if there was no value contributing to the average. This is mainly provided for sane averaging of
137+
achromatic colors in cylindrical/polar color spaces. With that said, any channel that has manually specified a channel
138+
as undefined will be treated in this manner.
81139

82140
```py play
83141
Color.average(['white', 'color(srgb 0 0 1)'], space='hsl')
@@ -90,17 +148,17 @@ distort the average in a non-meaningful way.
90148
Color.average(['hsl(30 0 100)', 'hsl(240 100 50 / 1)'], space='hsl')
91149
```
92150

93-
While undefined logic is intended to handle achromatic hues, this logic will be applied to any channel. It should be
94-
noted that no attempt to carry forward the undefined values through conversion is made at this time. Conversions will
95-
remove any undefined status unless the channel is an achromatic hues.
151+
As stated earlier, undefined logic is applied to any channel with undefined values. It should be noted that no attempt
152+
to carry forward the undefined values through conversion is made at this time. If conversion is required, the
153+
conversions will remove any undefined status unless the channel is an achromatic hues.
96154

97155
```py play
98156
for i in range(12):
99157
Color.average(['darkgreen', f'color(srgb 0 none 0 / {i / 11})', 'color(srgb 0 0 1)'])
100158
```
101159

102160
When `premultiplied` is enabled, premultiplication will not be applied to a color if its `alpha` is undefined as it is
103-
unknown how to weight the color, instead the color is treated with full weight.
161+
unknown how to weight the color. Instead, a color with undefined transparency will be treated with full weight.
104162

105163
```py play
106164
Color.average(['darkgreen', f'color(srgb 0 0.50196 0 / none)', 'color(srgb 0 0 1)'])

0 commit comments

Comments
 (0)