Repository: facelessuser/coloraide

Issue:
Weighted averaging of colors
We already have an average command that can return the mean of multiple colors. This can basically be used to mix multiple colors at 50%, and it also uses alpha premultiplication to give better results. Weights should be added to the API to allow specifying general weights for each color, allowing more control of mixing and increasing its usefulness.

We have a prototype working. Here is normal averaging.

![Image](https://github.com/user-attachments/assets/9f61b3d9-1ec7-4ed8-9e37-d3daa3c1c69c)

Here we average the colors with a weighting which sets the blue weight to zero:

![Image](https://github.com/user-attachments/assets/7f83ae50-d9cd-407e-a727-295c152b578b)

A couple of things need to be decided. We restrict weights to such that negative weights become zero. Do we do the same for above 1? If we track the total, we can normalize whatever the values are as they will scale relative to each other. Maybe we can even do some sort of incremental averaging 🤔. We also would need to do extensive testing to ensure this works well with various opacity cases.

Hints from issue comments:
@facelessuser: It will be required that number of weights match the number of colors. The scaling of weights is determined by the user. So if they want to do 2 parts red one part 1 part green, they can use 2 and 1 and it will be scaled to 1 and 0.5. If no weights are provided, the assumption is that all colors are 1. If the weights all sum to zero, you'll get a color with all components undefined.

Colors with zero weight will contribute nothing to the mix and should play nice with premultiplied alpha and non-premultiplied alpha. It should also play nicely with undefined channels. This is the area we need to test to make sure things work as expected. @facelessuser: I think we can relax the requirement that the number of weights must match the number of colors. Instead, if more weights are provided than colors, we will simply consume weights until the colors are satisfied. If more colors are provided than weights, all colors without weights will be assumed to have full weight. We can achieve this simply by tracking the biggest weight while weights are consumed, and then all colors without weights will adopt that maximum.



Solution patch:
diff --git a/coloraide/average.py b/coloraide/average.py
index cd32024d..4370e169 100644
--- a/coloraide/average.py
+++ b/coloraide/average.py
@@ -2,13 +2,27 @@
 from __future__ import annotations
 import math
 from . import util
+import itertools as it
 from .spaces import HWBish
 from .types import ColorInput, AnyColor
 from typing import Iterable
 
+
+class Sentinel(float):
+    """Sentinel object that is specific to averaging that we shouldn't see defined anywhere else."""
+
+
+def _iter_colors(colors: Iterable[ColorInput]) -> Iterable[tuple[ColorInput, float]]:
+    """Iterate colors and return weights."""
+
+    for c in colors:
+        yield c, 1.0
+
+
 def average(
     color_cls: type[AnyColor],
     colors: Iterable[ColorInput],
+    weights: Iterable[float] | None,
     space: str,
     premultiplied: bool = True
 ) -> AnyColor:
@@ -18,6 +32,7 @@ def average(
     Polar coordinates use a circular mean: https://en.wikipedia.org/wiki/Circular_mean.
     """
 
+    sentinel = Sentinel()
     obj = color_cls(space, [])
 
     # Get channel information
@@ -31,15 +46,39 @@ def average(
     channels = cs.channels
     chan_count = len(channels)
     alpha_index = chan_count - 1
-    sums = [0.0] * chan_count
-    totals = [0.0] * chan_count
+    avgs = [0.0] * chan_count
+    counts = [0] * chan_count
     sin = 0.0
     cos = 0.0
+    wavg = 0.0
+    no_weights = weights is None
+    if no_weights:
+        weights = ()
+    mx = 0.0
 
-    # Sum channel values
-    i = -1
-    for c in colors:
-        obj.update(c)
+    # Sum channel values using a rolling average. Apply premultiplication and additional weighting as required.
+    count = 0
+    for c, w in (_iter_colors(colors) if no_weights else it.zip_longest(colors, weights, fillvalue=sentinel)):  # type: ignore[arg-type]
+
+        # Handle explicit weighted cases
+        if not no_weights:
+            # If there are more weights than colors, ignore additional weights
+            if c is sentinel:
+                break
+
+            # If there are less weights than colors, assume full weight for colors without weights
+            if w is sentinel:
+                w = mx
+
+            # Negative weights are considered as zero weight
+            if w < 0.0:
+                w = 0.0
+
+            # Track the largest weight so we can populate colors with no weights
+            elif w > mx:
+                mx = w
+
+        obj.update(c)  # type: ignore[arg-type]
         # If cylindrical color is achromatic, ensure hue is undefined
         if hue_index >= 0 and not math.isnan(obj[hue_index]) and obj.is_achromatic():
             obj[hue_index] = math.nan
@@ -47,51 +86,58 @@ def average(
         alpha = coords[-1]
         if math.isnan(alpha):
             alpha = 1.0
+        walpha = alpha * w
+        count += 1
+        wavg += (w - wavg) / count
         i = 0
         for coord in coords:
             # 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
+            is_alpha = i == alpha_index
+            if not math.isnan(coord) and (premultiplied or alpha or is_alpha):
+                counts[i] += 1
+                n = counts[i]
                 if i == hue_index:
                     rad = math.radians(coord)
                     if premultiplied:
-                        sin += math.sin(rad) * alpha
-                        cos += math.cos(rad) * alpha
+                        sin += ((math.sin(rad) * walpha) - sin) / n
+                        cos += ((math.cos(rad) * walpha) - cos) / n
                     else:
-                        sin += math.sin(rad)
-                        cos += math.cos(rad)
+                        sin += ((math.sin(rad) * w) - sin) / n
+                        cos += ((math.cos(rad) * w) - cos) / n
                 else:
-                    sums[i] += (coord * alpha) if premultiplied and i != alpha_index else coord
+                    avgs[i] += (((coord * walpha) if premultiplied and not is_alpha else (coord * w)) - avgs[i]) / n
             i += 1
 
-    if i == -1:
+    if not count:
         raise ValueError('At least one color must be provided in order to average colors')
 
-    # Get the mean
-    sums[-1] = alpha = math.nan if not totals[-1] else (sums[-1] / totals[-1])
+    # Undo premultiplication and weighting to get the final color
+    w_factor = math.nan if not wavg else wavg
+    avgs[-1] = alpha = math.nan if not counts[-1] else avgs[-1] / w_factor
     if math.isnan(alpha):
         alpha = 1.0
+    walpha = alpha * w_factor
+
     for i in range(chan_count - 1):
-        total = totals[i]
-        if not total or not alpha:
-            sums[i] = math.nan
+        if not counts[i] or not alpha:
+            avgs[i] = math.nan
         elif i == hue_index:
             if premultiplied:
-                sin /= total * alpha
-                cos /= total * alpha
+                sin /= walpha
+                cos /= walpha
             else:
-                sin /= total
-                cos /= total
+                sin /= w_factor
+                cos /= w_factor
             if abs(sin) < util.ACHROMATIC_THRESHOLD_SM and abs(cos) < util.ACHROMATIC_THRESHOLD_SM:
-                sums[i] = math.nan
+                avgs[i] = math.nan
             else:
                 avg_theta = math.degrees(math.atan2(sin, cos))
-                sums[i] = (avg_theta + 360) if avg_theta < 0 else avg_theta
+                avgs[i] = (avg_theta + 360) if avg_theta < 0 else avg_theta
         else:
-            sums[i] /= (total * alpha) if premultiplied else total
+            avgs[i] /= walpha if premultiplied else w_factor
 
-    # Create the color and if polar and there is no defined hue, force an achromatic state.
-    color = obj.update(space, sums[:-1], sums[-1])
+    # Create the color. If polar and there is no defined hue, force an achromatic state.
+    color = obj.update(space, avgs[:-1], avgs[-1])
     if cs.is_polar():
         if is_hwb and math.isnan(color[hue_index]):
             w, b = cs.indexes()[1:]  # type: ignore[attr-defined]
diff --git a/coloraide/color.py b/coloraide/color.py
index ad5d3a78..6d0d121d 100644
--- a/coloraide/color.py
+++ b/coloraide/color.py
@@ -1145,6 +1145,7 @@ class Color(metaclass=ColorMeta):
     def average(
         cls,
         colors: Iterable[ColorInput],
+        weights: Iterable[float] | None = None,
         *,
         space: str | None = None,
         out_space: str | None = None,
@@ -1166,6 +1167,7 @@ class Color(metaclass=ColorMeta):
         return average.average(
             cls,
             colors,
+            weights,
             space,
             premultiplied
         ).convert(out_space, in_place=True)
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index b9201324..9f49b201 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -3,6 +3,7 @@
 ## 4.7
 
 -   **NEW**: Officially support Python 3.14.
+-   **NEW**: `average()` now accepts weights for weighted averaging.
 -   **ENHANCE**: Switch to deploying with PyPI's "Trusted Publisher".
 -   **ENHANCE**: Performance improvements for various algebraic calculations.
 -   **FIX**: Fix some corner cases with some algebraic calculations.
diff --git a/docs/src/markdown/average.md b/docs/src/markdown/average.md
index 24cb6d93..f537b03e 100644
--- a/docs/src/markdown/average.md
+++ b/docs/src/markdown/average.md
@@ -1,16 +1,26 @@
 # Color Averaging
 
-Color averaging is the process of calculating an average color from a set of other colors by taking the mean of each
-color channel.
+Color averaging is the process taking multiple colors and calculating an average color from them, essentially mixing
+all the colors together. This involves looking at each color channel of all colors under consideration and averaging
+and averaging each channel independently. Additionally, by default, transparency is taken into account by using
+premultiplication which weights the colors such that more opaque colors have a greater significance in the mixing vs
+more translucent colors.
+
+![Average RGB](images/avg-rgb.png)
+
 
 Averaging under ColorAide can take as many colors as desired and will return a color that represents the average. This
-is not to be confused with interpolation which employs a different technique, but in certain situations, it can sort of
-function like mixing multiple colors.
+is not to be confused with interpolation which employs a different technique. One thing that sets it apart from
+interpolation is that when performing the operation, the order of the colors does not matter and will yield the same
+results even the colors are shuffled.
+
+Averaging can be used as a way to mix multiple colors into one color or simply determine what the overall average color
+is from a set of colors. Results are subject to the geometry of the color space in which the average is performed.
 
 ## Rectangular Space Averaging
 
-ColorAide, by default, averages in rectangular color spaces, the default being Linear sRGB. If desired, other color
-spaces can be used, such as perceptually uniform spaces like Oklab.
+ColorAide, by default, averages in the rectangular Linear sRGB color spaces. If desired, other color spaces can be used.
+Results will vary due to the geometry of the color space being used.
 
 ```py play
 Color.average(['red', 'blue'])
@@ -18,7 +28,7 @@ Color.average(['red', 'blue'], space='srgb')
 Color.average(['red', 'blue'], space='oklab')
 ```
 
-Averaging is not restricted to any certain amount of colors.
+Averaging can be applied to any amount of colors.
 
 ```py play
 Color.average(['red', 'yellow', 'orange', 'green'])
@@ -26,19 +36,22 @@ Color.average(['red', 'yellow', 'orange', 'green'])
 
 ## Cylindrical Space Averaging
 
-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.
-
-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.
+ColorAide can also average colors in cylindrical spaces. When applying averaging in a cylindrical space, hues will be
+averaged taking the circular mean. Due the difference in approach, colors can be quite different.
 
 ```py play
 Color.average(['purple', 'green', 'blue'])
 Color.average(['purple', 'green', 'blue'], space='hsl')
 ```
 
-It should be noted that when averaging colors with hues which are evenly distributed around the color wheel, the result
+Colors that are deemed achromatic will have their hue treated as undefined, even if the hue is defined. This is to
+ensure that the average color makes sense and isn't tainted by a non-functional hue.
+
+```py play
+Color.average(['white', 'blue'], space='hsl')
+```
+
+It should be noted that when averaging colors with hues which are evenly distributed around color space, 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.
 
@@ -46,6 +59,49 @@ chroma/saturation information, producing an achromatic color.
 Color.average(['red', 'green', 'blue'], space='hsl')
 ```
 
+## Weighted Averaging
+
+To allow for greater control and nuance of mixing multiple colors, ColorAide allows weights to be defined to adjust how
+much a specific color is mixed relative to other colors.
+
+As an example, let's assume we wanted to mix `#!color orange` and `#!color red` but brighten it up with `#!color white`.
+More specifically, let's say we want 4 times the amount of white for every 1 part of the other colors. We can simply
+specify weights intuitively as `#!py [1, 1, 4]`.
+
+
+```py play
+Color.average(['orange', 'red', 'white'])
+Color.average(['orange', 'red', 'white'], [1, 1, 4])
+```
+
+![Weighted Average](images/avg-weighted.png)
+
+Regardless of how big or small the numbers are, they are scaled relative to the largest value, so internally,
+`#!py [1, 1, 4]` and `#!py [0.25, 0.25, 1]` are essentially the same.
+
+```py play
+Color.average(['orange', 'red', 'white'], [1, 1, 4])
+Color.average(['orange', 'red', 'white'], [0.25, 0.25, 1])
+```
+
+If more weights are provided that there are colors, the only the weights sufficient to satisfy the number of colors
+is consumed.
+
+```py play
+Color.average(['orange', 'red', 'white'], [1, 1, 4, 2, 1])
+```
+
+If more colors are provided than weights, colors without defined weights are assumed to be full weight.
+
+```py play
+Color.average(['orange', 'red', 'white'], [0, 1])
+```
+
+/// note
+It should be noted that negative weights are not allowed and will be clipped to zero, which treats the colors as if it
+is not included at all.
+///
+
 ## Averaging with Transparency
 
 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):
     )
 ```
 
-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.
+There are cases where this approach of averaging may not be desired and results are desired without considering
+transparency. If so, `premultiplied` can be disabled by setting it to `#!py False`. While the average of transparency is
+still 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
+It should also 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
@@ -76,8 +132,10 @@ for i in range(12):
 
 ## Averaging with Undefined Values
 
-When averaging with undefined values, ColorAide will not consider the undefined values in the average. This is mainly
-provided for averaging cylindrical colors, particularly achromatic colors.
+When averaging with undefined values, ColorAide will not consider the undefined values in the average. In short, it
+will be treated as if there was no value contributing to the average. This is mainly provided for sane averaging of
+achromatic colors in cylindrical/polar color spaces. With that said, any channel that has manually specified a channel
+as undefined will be treated in this manner.
 
 ```py play
 Color.average(['white', 'color(srgb 0 0 1)'], space='hsl')
@@ -90,9 +148,9 @@ distort the average in a non-meaningful way.
 Color.average(['hsl(30 0 100)', 'hsl(240 100 50 / 1)'], space='hsl')
 ```
 
-While undefined logic is intended to handle achromatic hues, this logic will be applied to any channel. It should be
-noted that no attempt to carry forward the undefined values through conversion is made at this time. Conversions will
-remove any undefined status unless the channel is an achromatic hues.
+As stated earlier, undefined logic is applied to any channel with undefined values. It should be noted that no attempt
+to carry forward the undefined values through conversion is made at this time. If conversion is required, the
+conversions will remove any undefined status unless the channel is an achromatic hues.
 
 ```py play
 for i in range(12):
@@ -100,7 +158,7 @@ for i in range(12):
 ```
 
 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.
+unknown how to weight the color. Instead, a color with undefined transparency will be treated with full weight.
 
 ```py play
 Color.average(['darkgreen', f'color(srgb 0 0.50196 0 / none)', 'color(srgb 0 0 1)'])
diff --git a/docs/src/markdown/demos/3d_models.html b/docs/src/markdown/demos/3d_models.html
index c5e19815..e9eed428 100644
--- a/docs/src/markdown/demos/3d_models.html
+++ b/docs/src/markdown/demos/3d_models.html
@@ -910,6 +910,82 @@ def plot_interpolation(
         showlegend=False
     )
 
+
+def plot_average(
+    fig,
+    space,
+    avg_colors,
+    avg_space,
+    gmap,
+    simulate_alpha,
+    avg_gmap
+):
+    """Plot interpolations."""
+
+    if not avg_colors:
+        return
+
+    parts = avg_colors.split(':')
+
+    colors = parts[0].split(';')
+    if len(parts) == 2:
+        weights = [float(i.strip()) for i in parts[1].split(',')]
+    else:
+        weights = None
+
+    color = Color.average(
+        colors,
+        weights,
+        space=avg_space
+    )
+
+    target = Color.CS_MAP[space]
+    flags = {
+        'is_cyl': target.is_polar(),
+        'is_labish': isinstance(target, Labish),
+        'is_lchish': isinstance(target, LChish),
+        'is_hslish': isinstance(target, HSLish),
+        'is_hwbish': isinstance(target, HWBish),
+        'is_hsvish': isinstance(target, HSVish)
+    }
+
+    for s in colors:
+        x = []
+        y = []
+        z = []
+        cmap = []
+        c = Color(s).convert(space, in_place=True).normalize(nans=False)
+        if avg_gmap:
+            c.fit('srgb', **gmap)
+        store_coords(c, x, y, z, flags)
+        c.convert('srgb', in_place=True)
+        c.fit(**gmap)
+        if simulate_alpha:
+            cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True))
+        else:
+           cmap.append(c.to_string(comma=True, alpha=False))
+
+        c = Color(color).convert(space, in_place=True).normalize(nans=False)
+        if avg_gmap:
+            c.fit('srgb', **gmap)
+        store_coords(c, x, y, z, flags)
+        c.convert('srgb', in_place=True)
+        c.fit(**gmap)
+        if simulate_alpha:
+            cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True))
+        else:
+            cmap.append(c.to_string(comma=True, alpha=False))
+
+        trace = go.Scatter3d(
+            x=x, y=y, z=z,
+            marker={'size': [8, 16], 'color': cmap, 'opacity': 1},
+            line={'color': cmap[0], 'width': 3},
+            showlegend=False
+        )
+
+        fig.add_trace(trace)
+
+
     fig.add_trace(trace)
 </script>
 <script type="text/javascript">
diff --git a/tools/gamut_3d_plotly.py b/tools/gamut_3d_plotly.py
index 95339a30..7cb25544 100644
--- a/tools/gamut_3d_plotly.py
+++ b/tools/gamut_3d_plotly.py
@@ -713,6 +713,81 @@ def plot_interpolation(
     fig.add_trace(trace)
 
 
+def plot_average(
+    fig,
+    space,
+    avg_colors,
+    avg_space,
+    gmap,
+    simulate_alpha,
+    avg_gmap
+):
+    """Plot interpolations."""
+
+    if not avg_colors:
+        return
+
+    parts = avg_colors.split(':')
+
+    colors = parts[0].split(';')
+    if len(parts) == 2:
+        weights = [float(i.strip()) for i in parts[1].split(',')]
+    else:
+        weights = None
+
+    color = Color.average(
+        colors,
+        weights,
+        space=avg_space
+    )
+
+    target = Color.CS_MAP[space]
+    flags = {
+        'is_cyl': target.is_polar(),
+        'is_labish': isinstance(target, Labish),
+        'is_lchish': isinstance(target, LChish),
+        'is_hslish': isinstance(target, HSLish),
+        'is_hwbish': isinstance(target, HWBish),
+        'is_hsvish': isinstance(target, HSVish)
+    }
+
+    for s in colors:
+        x = []
+        y = []
+        z = []
+        cmap = []
+        c = Color(s).convert(space, in_place=True).normalize(nans=False)
+        if avg_gmap:
+            c.fit('srgb', **gmap)
+        store_coords(c, x, y, z, flags)
+        c.convert('srgb', in_place=True)
+        c.fit(**gmap)
+        if simulate_alpha:
+            cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True))
+        else:
+           cmap.append(c.to_string(comma=True, alpha=False))
+
+        c = Color(color).convert(space, in_place=True).normalize(nans=False)
+        if avg_gmap:
+            c.fit('srgb', **gmap)
+        store_coords(c, x, y, z, flags)
+        c.convert('srgb', in_place=True)
+        c.fit(**gmap)
+        if simulate_alpha:
+            cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True))
+        else:
+            cmap.append(c.to_string(comma=True, alpha=False))
+
+        trace = go.Scatter3d(
+            x=x, y=y, z=z,
+            marker={'size': [8, 16], 'color': cmap, 'opacity': 1},
+            line={'color': cmap[0], 'width': 3},
+            showlegend=False
+        )
+
+        fig.add_trace(trace)
+
+
 def main():
     """Main."""
 
@@ -756,14 +831,15 @@ def main():
     )
 
     # Interpolation visualization
+    parser.add_argument('--avg-colors', default='', help="Colors that should be averaged together.")
     parser.add_argument('--interp-colors', default='', help='Interpolation colors separated by semicolons.')
     parser.add_argument('--interp-method', default='linear', help="Interplation method to use: linear, bezier, etc.")
-    parser.add_argument('--interp-space', default='oklab', help="Interpolation space.")
+    parser.add_argument('--interp-space', default='oklab', help="Interpolation/averaging space.")
     parser.add_argument(
-        '--interp-alpha', action='store_true', help="Simulate interpolation opacity by overlaying on white"
+        '--interp-alpha', action='store_true', help="Simulate interpolation/averaging opacity by overlaying on white."
     )
     parser.add_argument(
-        '--interp-gmap', action='store_true', help="Force plotted interpolation results to be gamut mapped."
+        '--interp-gmap', action='store_true', help="Force plotted interpolation/averaging results to be gamut mapped."
     )
     parser.add_argument('--hue', default='shorter', help="Hue interpolation handling.")
     parser.add_argument('--extrapolate', action='store_true', help='Extrapolate values.')
@@ -861,6 +937,16 @@ def main():
         args.interp_gmap
     )
 
+    plot_average(
+        fig,
+        args.space,
+        args.avg_colors,
+        args.interp_space,
+        gmap,
+        args.interp_alpha,
+        args.interp_gmap
+    )
+
     # Plot gamut mapping examples
     plot_colors(fig, args.space, args.gamut, args.gmap_colors, args.colors, gmap)
 


Test patch:
diff --git a/tests/test_average.py b/tests/test_average.py
index b23ab9d7..c61dc815 100644
--- a/tests/test_average.py
+++ b/tests/test_average.py
@@ -167,3 +167,35 @@ class TestAverage(util.ColorAsserts, unittest.TestCase):
         a = Color.average(['color(srgb 1 none 0)', 'color(srgb 0.1 none 0.3)', 'color(srgb 0.7 none 0)'], space='srgb')
         self.assertColorEqual(a, Color('rgb(153 none 25.5)'))
         self.assertTrue(math.isnan(a[1]))
+
+    def test_weighted_colors(self):
+        """Test weighted colors."""
+
+        a = Color.average(['red', 'green', 'yellow', 'blue'], space='srgb')
+        a = self.assertColorEqual(a, Color('color(srgb 0.5 0.37549 0.25 / 1)'))
+        a = Color.average(['red', 'green', 'yellow', 'blue'], [1, 1, 1, 0], space='srgb')
+        a = self.assertColorEqual(a, Color('rgb(170 127.67 0)'))
+        a = Color.average(['red', 'green', 'yellow', 'blue'], [1, 4, 2, 3], space='srgb')
+        a = self.assertColorEqual(a, Color('rgb(76.5 102.2 76.5)'))
+
+    def test_weighted_polar_colors(self):
+        """Test weighted colors."""
+
+        a = Color.average(['red', 'orange', 'white'], space='hsl')
+        a = self.assertColorEqual(a, Color('hsl(19.412 66.667% 66.667%)'))
+        a = Color.average(['red', 'orange', 'white'], [1, 1, 4], space='hsl')
+        a = self.assertColorEqual(a, Color('hsl(19.412 33.333% 83.333%)'))
+
+    def test_weighted_colors_mismatch(self):
+        """Test weighted colors."""
+
+        a = Color.average(['red', 'green', 'yellow', 'blue'], [1, 4, 2], space='srgb')
+        a = self.assertColorEqual(a, Color('rgb(69.545 92.909 92.727)'))
+        a = Color.average(['red', 'green', 'yellow', 'blue'], [1, 4, 2, 3, 5, 6], space='srgb')
+        a = self.assertColorEqual(a, Color('rgb(76.5 102.2 76.5)'))
+
+    def test_weighted_negative(self):
+        """Test weighted with negative weights."""
+
+        a = Color.average(['red', 'green', 'yellow', 'blue'], [1, 1, 1, -1], space='srgb')
+        a = self.assertColorEqual(a, Color('rgb(170 127.67 0)'))