Skip to content

fix: don't double-weight first color's hue in average - #391

Open
chatman-media wants to merge 1 commit into
gka:mainfrom
chatman-media:fix/average-hue-double-weight
Open

fix: don't double-weight first color's hue in average#391
chatman-media wants to merge 1 commit into
gka:mainfrom
chatman-media:fix/average-hue-double-weight

Conversation

@chatman-media

Copy link
Copy Markdown

Problem

In chroma.average(colors, mode, weights), when mode has a hue channel (hsl, hsv, lch, hcl, hcg, …), the first color's hue gets its weight applied twice.

In the "initial color" loop, xyz[i] is multiplied by weights[0] before the hue angle is computed:

xyz[i] = (xyz[i] || 0) * weights[0];          // hue value scaled by the weight
...
if (mode.charAt(i) === 'h' && !isNaN(xyz[i])) {
    const A = (xyz[i] / 180) * PI;            // angle is now hue*weight/180*PI  ← wrong
    dx += cos(A) * weights[0];
    dy += sin(A) * weights[0];
}

The angle A should be derived from the raw hue, with the weight applied only to the cos/sin contributions — which is exactly what the loop over the remaining colors does. Because the first color's angle is scaled, its direction on the color wheel is corrupted whenever weights[0] !== 1.

Closes #252. This is also why averaging hues produces order-dependent / wrong results as reported in #282.

Why existing tests didn't catch it

Every weighted-hue test had a first color whose hue is 0 (red), and 0 * weight == 0, so the corruption was invisible. The bug only shows up when the first color's hue is non-zero and its weight is not 1.

Reproduction

A weighted circular mean must be independent of the order the colors are listed in. Before the fix it isn't:

chroma.average(['cyan', 'red'], 'hsl', [1, 4]).get('hsl.h')  // 12.45  ✗
chroma.average(['red', 'cyan'], 'hsl', [4, 1]).get('hsl.h')  //  0.00  ✓

Red (hue 0°) carries 4× the weight of cyan (hue 180°), so the result must sit at ~0° regardless of order. Same for lch:

chroma.average(['#ff0000', '#00ff00'], 'lch', [3, 1]).get('lch.h')  // 68.89 ✗
chroma.average(['#00ff00', '#ff0000'], 'lch', [1, 3]).get('lch.h')  // 45.69 ✗

Reference value

Computing the weighted circular mean directly — atan2(Σ wⱼ·sin(hⱼ), Σ wⱼ·cos(hⱼ)) — for #ff0000 (lch hue 40.85°, weight 3) and #00ff00 (lch hue 136.02°, weight 1) gives 51.68°, matching colorjs.io / the standard mean-of-circular-quantities formula. After the fix both orderings return 51.68° and the same color #ff6300.

Fix

Mirror the logic already used for the remaining colors: in the initial-color loop, compute the hue angle from the raw value and don't fold the weight into xyz[i] for the hue channel (the final loop overwrites it with the resultant angle anyway). Non-hue channels are unchanged.

Tests

Added two tests asserting order-independence of the weighted hue average (hsl + lch) plus the reference value. Both fail on main and pass with the fix. Full suite: 2521 passed.

@chatman-media
chatman-media requested a review from gka as a code owner June 18, 2026 19:31
@changeset-bot

changeset-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 796356e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

average applies weight to first hue twice

1 participant