diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index 4b32e1669..d6196a946 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -3,7 +3,7 @@ namespace Terminal.Gui; /// -/// Translates colors in an image into a Palette of up to 256 colors. +/// Translates colors in an image into a Palette of up to colors (typically 256). /// public class ColorQuantizer { @@ -21,14 +21,14 @@ public class ColorQuantizer /// /// Gets or sets the algorithm used to map novel colors into existing - /// palette colors (closest match). Defaults to + /// palette colors (closest match). Defaults to /// - public IColorDistance DistanceAlgorithm { get; set; } = new CIE94ColorDistance (); + public IColorDistance DistanceAlgorithm { get; set; } = new EuclideanColorDistance (); /// /// Gets or sets the algorithm used to build the . /// - public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new MedianCutPaletteBuilder (new EuclideanColorDistance ()) ; + public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (),50) ; public void BuildPalette (Color [,] pixels) { diff --git a/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs b/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs index 49d754ed4..e04a63972 100644 --- a/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs +++ b/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs @@ -1,11 +1,24 @@ namespace Terminal.Gui; /// +/// /// Calculates the distance between two colors using Euclidean distance in 3D RGB space. /// This measures the straight-line distance between the two points representing the colors. +/// +/// +/// Euclidean distance in RGB space is calculated as: +/// +/// +/// √((R2 - R1)² + (G2 - G1)² + (B2 - B1)²) +/// +/// Values vary from 0 to ~441.67 linearly +/// +/// This distance metric is commonly used for comparing colors in RGB space, though +/// it doesn't account for perceptual differences in color. /// public class EuclideanColorDistance : IColorDistance { + /// public double CalculateDistance (Color c1, Color c2) { int rDiff = c1.R - c2.R; diff --git a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs deleted file mode 100644 index 514865dc7..000000000 --- a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs +++ /dev/null @@ -1,141 +0,0 @@ -using Terminal.Gui; -using Color = Terminal.Gui.Color; - -public class MedianCutPaletteBuilder : IPaletteBuilder -{ - private readonly IColorDistance _colorDistance; - - public MedianCutPaletteBuilder (IColorDistance colorDistance) - { - _colorDistance = colorDistance; - } - - public List BuildPalette (List colors, int maxColors) - { - if (colors == null || colors.Count == 0 || maxColors <= 0) - { - return new List (); - } - - return MedianCut (colors, maxColors); - } - - private List MedianCut (List colors, int maxColors) - { - var cubes = new List> () { colors }; - - // Recursively split color regions - while (cubes.Count < maxColors) - { - bool added = false; - cubes.Sort ((a, b) => Volume (a).CompareTo (Volume (b))); - - var largestCube = cubes.Last (); - cubes.RemoveAt (cubes.Count - 1); - - // Check if the largest cube contains only one unique color - if (IsSingleColorCube (largestCube)) - { - // Add back and stop splitting this cube - cubes.Add (largestCube); - break; - } - - var (cube1, cube2) = SplitCube (largestCube); - - if (cube1.Any ()) - { - cubes.Add (cube1); - added = true; - } - - if (cube2.Any ()) - { - cubes.Add (cube2); - added = true; - } - - // Break the loop if no new cubes were added - if (!added) - { - break; - } - } - - // Calculate average color for each cube - return cubes.Select (AverageColor).Distinct ().ToList (); - } - - // Checks if all colors in the cube are the same - private bool IsSingleColorCube (List cube) - { - var firstColor = cube.First (); - return cube.All (c => c.R == firstColor.R && c.G == firstColor.G && c.B == firstColor.B); - } - - // Splits the cube based on the largest color component range - private (List, List) SplitCube (List cube) - { - var (component, range) = FindLargestRange (cube); - - // Sort by the largest color range component (either R, G, or B) - cube.Sort ((c1, c2) => component switch - { - 0 => c1.R.CompareTo (c2.R), - 1 => c1.G.CompareTo (c2.G), - 2 => c1.B.CompareTo (c2.B), - _ => 0 - }); - - var medianIndex = cube.Count / 2; - var cube1 = cube.Take (medianIndex).ToList (); - var cube2 = cube.Skip (medianIndex).ToList (); - - return (cube1, cube2); - } - - private (int, int) FindLargestRange (List cube) - { - var minR = cube.Min (c => c.R); - var maxR = cube.Max (c => c.R); - var minG = cube.Min (c => c.G); - var maxG = cube.Max (c => c.G); - var minB = cube.Min (c => c.B); - var maxB = cube.Max (c => c.B); - - var rangeR = maxR - minR; - var rangeG = maxG - minG; - var rangeB = maxB - minB; - - if (rangeR >= rangeG && rangeR >= rangeB) return (0, rangeR); - if (rangeG >= rangeR && rangeG >= rangeB) return (1, rangeG); - return (2, rangeB); - } - - private Color AverageColor (List cube) - { - var avgR = (byte)(cube.Average (c => c.R)); - var avgG = (byte)(cube.Average (c => c.G)); - var avgB = (byte)(cube.Average (c => c.B)); - - return new Color (avgR, avgG, avgB); - } - - private int Volume (List cube) - { - if (cube == null || cube.Count == 0) - { - // Return a volume of 0 if the cube is empty or null - return 0; - } - - var minR = cube.Min (c => c.R); - var maxR = cube.Max (c => c.R); - var minG = cube.Min (c => c.G); - var maxG = cube.Max (c => c.G); - var minB = cube.Min (c => c.B); - var maxB = cube.Max (c => c.B); - - return (maxR - minR) * (maxG - minG) * (maxB - minB); - } -} diff --git a/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs new file mode 100644 index 000000000..2f767b4c2 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs @@ -0,0 +1,106 @@ +using Terminal.Gui; +using Color = Terminal.Gui.Color; + +/// +/// Simple fast palette building algorithm which uses the frequency that a color is seen +/// to determine whether it will appear in the final palette. Includes a threshold where +/// by colors will be considered 'the same'. This reduces the chance of under represented +/// colors being missed completely. +/// +public class PopularityPaletteWithThreshold : IPaletteBuilder +{ + private readonly IColorDistance _colorDistance; + private readonly double _mergeThreshold; + + public PopularityPaletteWithThreshold (IColorDistance colorDistance, double mergeThreshold) + { + _colorDistance = colorDistance; + _mergeThreshold = mergeThreshold; // Set the threshold for merging similar colors + } + + public List BuildPalette (List colors, int maxColors) + { + if (colors == null || colors.Count == 0 || maxColors <= 0) + { + return new (); + } + + // Step 1: Build the histogram of colors (count occurrences) + Dictionary colorHistogram = new Dictionary (); + + foreach (Color color in colors) + { + if (colorHistogram.ContainsKey (color)) + { + colorHistogram [color]++; + } + else + { + colorHistogram [color] = 1; + } + } + + // If we already have fewer or equal colors than the limit, no need to merge + if (colorHistogram.Count <= maxColors) + { + return colorHistogram.Keys.ToList (); + } + + // Step 2: Merge similar colors using the color distance threshold + Dictionary mergedHistogram = MergeSimilarColors (colorHistogram, maxColors); + + // Step 3: Sort the histogram by frequency (most frequent colors first) + List sortedColors = mergedHistogram.OrderByDescending (c => c.Value) + .Take (maxColors) // Keep only the top `maxColors` colors + .Select (c => c.Key) + .ToList (); + + return sortedColors; + } + + /// + /// Merge colors in the histogram if they are within the threshold distance + /// + /// + /// + private Dictionary MergeSimilarColors (Dictionary colorHistogram, int maxColors) + { + Dictionary mergedHistogram = new Dictionary (); + + foreach (KeyValuePair entry in colorHistogram) + { + Color currentColor = entry.Key; + var merged = false; + + // Try to merge the current color with an existing entry in the merged histogram + foreach (Color mergedEntry in mergedHistogram.Keys.ToList ()) + { + double distance = _colorDistance.CalculateDistance (currentColor, mergedEntry); + + // If the colors are similar enough (within the threshold), merge them + if (distance <= _mergeThreshold) + { + mergedHistogram [mergedEntry] += entry.Value; // Add the color frequency to the existing one + merged = true; + + break; + } + } + + // If no similar color is found, add the current color as a new entry + if (!merged) + { + mergedHistogram [currentColor] = entry.Value; + } + + + // Early exit if we've reduced the colors to the maxColors limit + if (mergedHistogram.Count <= maxColors) + { + return mergedHistogram; + } + } + + return mergedHistogram; + } +} diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index f929cabab..4c47239bc 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -357,3 +357,150 @@ public class CIE76ColorDistance : LabColorDistance return Math.Sqrt (Math.Pow (lab1.L - lab2.L, 2) + Math.Pow (lab1.A - lab2.A, 2) + Math.Pow (lab1.B - lab2.B, 2)); } } + +public class MedianCutPaletteBuilder : IPaletteBuilder +{ + private readonly IColorDistance _colorDistance; + + public MedianCutPaletteBuilder (IColorDistance colorDistance) { _colorDistance = colorDistance; } + + public List BuildPalette (List colors, int maxColors) + { + if (colors == null || colors.Count == 0 || maxColors <= 0) + { + return new (); + } + + return MedianCut (colors, maxColors); + } + + private List MedianCut (List colors, int maxColors) + { + List> cubes = new() { colors }; + + // Recursively split color regions + while (cubes.Count < maxColors) + { + var added = false; + cubes.Sort ((a, b) => Volume (a).CompareTo (Volume (b))); + + List largestCube = cubes.Last (); + cubes.RemoveAt (cubes.Count - 1); + + // Check if the largest cube contains only one unique color + if (IsSingleColorCube (largestCube)) + { + // Add back and stop splitting this cube + cubes.Add (largestCube); + + break; + } + + (List cube1, List cube2) = SplitCube (largestCube); + + if (cube1.Any ()) + { + cubes.Add (cube1); + added = true; + } + + if (cube2.Any ()) + { + cubes.Add (cube2); + added = true; + } + + // Break the loop if no new cubes were added + if (!added) + { + break; + } + } + + // Calculate average color for each cube + return cubes.Select (AverageColor).Distinct ().ToList (); + } + + // Checks if all colors in the cube are the same + private bool IsSingleColorCube (List cube) + { + Color firstColor = cube.First (); + + return cube.All (c => c.R == firstColor.R && c.G == firstColor.G && c.B == firstColor.B); + } + + // Splits the cube based on the largest color component range + private (List, List) SplitCube (List cube) + { + (int component, int range) = FindLargestRange (cube); + + // Sort by the largest color range component (either R, G, or B) + cube.Sort ( + (c1, c2) => component switch + { + 0 => c1.R.CompareTo (c2.R), + 1 => c1.G.CompareTo (c2.G), + 2 => c1.B.CompareTo (c2.B), + _ => 0 + }); + + int medianIndex = cube.Count / 2; + List cube1 = cube.Take (medianIndex).ToList (); + List cube2 = cube.Skip (medianIndex).ToList (); + + return (cube1, cube2); + } + + private (int, int) FindLargestRange (List cube) + { + byte minR = cube.Min (c => c.R); + byte maxR = cube.Max (c => c.R); + byte minG = cube.Min (c => c.G); + byte maxG = cube.Max (c => c.G); + byte minB = cube.Min (c => c.B); + byte maxB = cube.Max (c => c.B); + + int rangeR = maxR - minR; + int rangeG = maxG - minG; + int rangeB = maxB - minB; + + if (rangeR >= rangeG && rangeR >= rangeB) + { + return (0, rangeR); + } + + if (rangeG >= rangeR && rangeG >= rangeB) + { + return (1, rangeG); + } + + return (2, rangeB); + } + + private Color AverageColor (List cube) + { + var avgR = (byte)cube.Average (c => c.R); + var avgG = (byte)cube.Average (c => c.G); + var avgB = (byte)cube.Average (c => c.B); + + return new (avgR, avgG, avgB); + } + + private int Volume (List cube) + { + if (cube == null || cube.Count == 0) + { + // Return a volume of 0 if the cube is empty or null + return 0; + } + + byte minR = cube.Min (c => c.R); + byte maxR = cube.Max (c => c.R); + byte minG = cube.Min (c => c.G); + byte maxG = cube.Max (c => c.G); + byte minB = cube.Min (c => c.B); + byte maxB = cube.Max (c => c.B); + + return (maxR - minR) * (maxG - minG) * (maxB - minB); + } +} diff --git a/UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs b/UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs new file mode 100644 index 000000000..7de04c652 --- /dev/null +++ b/UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs @@ -0,0 +1,118 @@ +namespace Terminal.Gui.DrawingTests; + +public class PopularityPaletteWithThresholdTests +{ + private readonly IColorDistance _colorDistance; + + public PopularityPaletteWithThresholdTests () { _colorDistance = new EuclideanColorDistance (); } + + [Fact] + public void BuildPalette_EmptyColorList_ReturnsEmptyPalette () + { + // Arrange + var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); + List colors = new (); + + // Act + List result = paletteBuilder.BuildPalette (colors, 256); + + // Assert + Assert.Empty (result); + } + + [Fact] + public void BuildPalette_MaxColorsZero_ReturnsEmptyPalette () + { + // Arrange + var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); + List colors = new() { new (255, 0), new (0, 255) }; + + // Act + List result = paletteBuilder.BuildPalette (colors, 0); + + // Assert + Assert.Empty (result); + } + + [Fact] + public void BuildPalette_SingleColorList_ReturnsSingleColor () + { + // Arrange + var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); + List colors = new() { new (255, 0), new (255, 0) }; + + // Act + List result = paletteBuilder.BuildPalette (colors, 256); + + // Assert + Assert.Single (result); + Assert.Equal (new (255, 0), result [0]); + } + + [Fact] + public void BuildPalette_ThresholdMergesSimilarColors_WhenColorCountExceedsMax () + { + // Arrange + var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); // Set merge threshold to 50 + + List colors = new List + { + new (255, 0), // Red + new (250, 0), // Very close to Red + new (0, 255), // Green + new (0, 250) // Very close to Green + }; + + // Act + List result = paletteBuilder.BuildPalette (colors, 2); // Limit palette to 2 colors + + // Assert + Assert.Equal (2, result.Count); // Red and Green should be merged with their close colors + Assert.Contains (new (255, 0), result); // Red (or close to Red) should be present + Assert.Contains (new (0, 255), result); // Green (or close to Green) should be present + } + + [Fact] + public void BuildPalette_NoMergingIfColorCountIsWithinMax () + { + // Arrange + var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); + + List colors = new() + { + new (255, 0), // Red + new (0, 255) // Green + }; + + // Act + List result = paletteBuilder.BuildPalette (colors, 256); // Set maxColors higher than the number of unique colors + + // Assert + Assert.Equal (2, result.Count); // No merging should occur since we are under the limit + Assert.Contains (new (255, 0), result); + Assert.Contains (new (0, 255), result); + } + + [Fact] + public void BuildPalette_MergesUntilMaxColorsReached () + { + // Arrange + var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); + + List colors = new List + { + new (255, 0), // Red + new (254, 0), // Close to Red + new (0, 255), // Green + new (0, 254) // Close to Green + }; + + // Act + List result = paletteBuilder.BuildPalette (colors, 2); // Set maxColors to 2 + + // Assert + Assert.Equal (2, result.Count); // Only two colors should be in the final palette + Assert.Contains (new (255, 0), result); + Assert.Contains (new (0, 255), result); + } +}