diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs b/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs index 0c47a8ed3f..7fa2dfdddd 100644 --- a/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs +++ b/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs @@ -11,11 +11,11 @@ namespace Avalonia.Controls.Primitives /// public static class ColorHelper { - private static readonly Dictionary _cachedDisplayNames = new Dictionary(); - private static readonly Dictionary _cachedKnownColorHues = new Dictionary(); + private static readonly Dictionary _cachedDisplayNames = new Dictionary(); private static readonly Dictionary _cachedKnownColorNames = new Dictionary(); private static readonly object _displayNameCacheMutex = new object(); private static readonly object _knownColorCacheMutex = new object(); + private static readonly KnownColor[] _knownColors = (KnownColor[])Enum.GetValues(typeof(KnownColor)); /// /// Gets the relative (perceptual) luminance/brightness of the given color. @@ -62,7 +62,36 @@ namespace Avalonia.Controls.Primitives /// The approximate color display name. public static string ToDisplayName(Color color) { - // Without rounding, there are 16,777,216 possible RGB colors (without alpha). + var hsvColor = color.ToHsv(); + + // Handle extremes that are outside the below algorithm + if (color.A == 0x00) + { + return GetDisplayName(KnownColor.Transparent); + } + + // HSV ---------------------------------------------------------------------- + // + // There are far too many possible HSV colors to cache and search through + // for performance reasons. Therefore, the HSV color is rounded. + // Rounding is tolerable in this algorithm because it is perception based. + // Hue is the most important for user perception so is rounded the least. + // Then there is a lot of loss in rounding the saturation and value components + // which are not as closely related to perceived color. + // + // Hue : Round to nearest int (0..360) + // Saturation : Round to the nearest 1/10 (0..1) + // Value : Round to the nearest 1/10 (0..1) + // Alpha : Is ignored in this algorithm + // + // Rounding results in ~36_000 values to cache in the worse case. + // + // RGB ---------------------------------------------------------------------- + // + // The original algorithm worked in RGB color space. + // If this code is every adjusted to work in RGB again note the following: + // + // Without rounding, there are 16_777_216 possible RGB colors (without alpha). // This is too many to cache and search through for performance reasons. // It is also needlessly large as there are only ~140 known/named colors. // Therefore, rounding of the input color's component values is done to @@ -71,58 +100,29 @@ namespace Avalonia.Controls.Primitives // The rounding value of 5 is specially chosen. // It is a factor of 255 and therefore evenly divisible which improves // the quality of the calculations. - double rounding = 5; - var roundedColor = new Color( - 0xFF, - Convert.ToByte(Math.Round(color.R / rounding) * rounding), - Convert.ToByte(Math.Round(color.G / rounding) * rounding), - Convert.ToByte(Math.Round(color.B / rounding) * rounding)); - var hsvColor = color.ToHsv(); - - // Handle extremes that are outside the below algorithm - if (color.A == 0x00) - { - return GetDisplayName(KnownColor.Transparent); - } - else if (hsvColor.S <= 0.0) - { - return GetDisplayName(KnownColor.White); - } - else if (hsvColor.V <= 0.0) - { - return GetDisplayName(KnownColor.Black); - } + var roundedHsvColor = new HsvColor( + 1.0, + Math.Round(hsvColor.H, 0), + Math.Round(hsvColor.S, 1), + Math.Round(hsvColor.V, 1)); // Attempt to use a previously cached display name lock (_displayNameCacheMutex) { - if (_cachedDisplayNames.TryGetValue(roundedColor, out var displayName)) + if (_cachedDisplayNames.TryGetValue(roundedHsvColor, out var displayName)) { return displayName; } } - // Build KnownColor caches if they don't already exist + // Build the KnownColor name cache if it doesn't already exist lock (_knownColorCacheMutex) { - if (_cachedKnownColorHues.Count == 0 || - _cachedKnownColorNames.Count == 0) + if (_cachedKnownColorNames.Count == 0) { - _cachedKnownColorHues.Clear(); - _cachedKnownColorNames.Clear(); - - var knownColors = (KnownColor[])Enum.GetValues(typeof(KnownColor)); - for (int i = 1; i < knownColors.Length; i++) // Skip 'None' so start at 1 + for (int i = 1; i < _knownColors.Length; i++) // Skip 'None' so start at 1 { - KnownColor knownColor = knownColors[i]; - - // Transparent is skipped since alpha is ignored making it equivalent to White - if (knownColor == KnownColor.Transparent) - { - continue; - } - - double hue = KnownColors.ToColor(knownColor).ToHsv().H; + KnownColor knownColor = _knownColors[i]; // Some known colors have the same numerical value. For example: // - Aqua = 0xff00ffff @@ -131,11 +131,6 @@ namespace Avalonia.Controls.Primitives // This is not possible to represent in a dictionary which requires // unique values. Therefore, only the first value is used. - if (!_cachedKnownColorHues.ContainsKey(knownColor)) - { - _cachedKnownColorHues.Add(knownColor, hue); - } - if (!_cachedKnownColorNames.ContainsKey(knownColor)) { _cachedKnownColorNames.Add(knownColor, GetDisplayName(knownColor)); @@ -144,24 +139,29 @@ namespace Avalonia.Controls.Primitives } } - // Find the closest known color by finding nearest Hue - // Since Hue is the best measure of human perception of the color itself - // it is not necessary to check other components (Saturation, Value). + // Find the closest known color by measuring 3D Euclidean distance (ignore alpha) + // This is done in HSV color space to most closely match user-perception var closestKnownColor = KnownColor.None; - var closestKnownColorHueDiff = double.PositiveInfinity; + var closestKnownColorDistance = double.PositiveInfinity; - lock (_knownColorCacheMutex) + for (int i = 1; i < _knownColors.Length; i++) // Skip 'None' so start at 1 { - foreach (var hueEntry in _cachedKnownColorHues) + KnownColor knownColor = _knownColors[i]; + + // Transparent is skipped since alpha is ignored making it equivalent to White + if (knownColor != KnownColor.Transparent) { - // Closest hue before or after is allowed - // Therefore, use an absolute value - double difference = Math.Abs(hsvColor.H - hueEntry.Value); + HsvColor knownHsvColor = KnownColors.ToColor(knownColor).ToHsv(); + + double distance = Math.Sqrt( + Math.Pow((roundedHsvColor.H - knownHsvColor.H), 2.0) + + Math.Pow((roundedHsvColor.S - knownHsvColor.S), 2.0) + + Math.Pow((roundedHsvColor.V - knownHsvColor.V), 2.0)); - if (difference < closestKnownColorHueDiff) + if (distance < closestKnownColorDistance) { - closestKnownColor = hueEntry.Key; - closestKnownColorHueDiff = difference; + closestKnownColor = knownColor; + closestKnownColorDistance = distance; } } } @@ -182,7 +182,7 @@ namespace Avalonia.Controls.Primitives lock (_displayNameCacheMutex) { - _cachedDisplayNames.Add(roundedColor, displayName); + _cachedDisplayNames.Add(roundedHsvColor, displayName); } return displayName;