Browse Source

Switch to simpler and faster palette builder

tznind 10 months ago
parent
commit
f07ab92dca

+ 4 - 4
Terminal.Gui/Drawing/Quant/ColorQuantizer.cs

@@ -3,7 +3,7 @@
 namespace Terminal.Gui;
 
 /// <summary>
-/// Translates colors in an image into a Palette of up to 256 colors.
+/// Translates colors in an image into a Palette of up to <see cref="MaxColors"/> colors (typically 256).
 /// </summary>
 public class ColorQuantizer
 {
@@ -21,14 +21,14 @@ public class ColorQuantizer
 
     /// <summary>
     /// Gets or sets the algorithm used to map novel colors into existing
-    /// palette colors (closest match). Defaults to <see cref="CIE94ColorDistance"/>
+    /// palette colors (closest match). Defaults to <see cref="EuclideanColorDistance"/>
     /// </summary>
-    public IColorDistance DistanceAlgorithm { get; set; } = new CIE94ColorDistance ();
+    public IColorDistance DistanceAlgorithm { get; set; } = new EuclideanColorDistance ();
 
     /// <summary>
     /// Gets or sets the algorithm used to build the <see cref="Palette"/>.
     /// </summary>
-    public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new MedianCutPaletteBuilder (new EuclideanColorDistance ()) ;
+    public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (),50) ;
 
     public void BuildPalette (Color [,] pixels)
     {

+ 13 - 0
Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs

@@ -1,11 +1,24 @@
 namespace Terminal.Gui;
 
 /// <summary>
+/// <para>
 /// 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.
+///</para>
+/// <para>
+/// Euclidean distance in RGB space is calculated as:
+/// </para>
+/// <code>
+///     √((R2 - R1)² + (G2 - G1)² + (B2 - B1)²)
+/// </code>
+/// <remarks>Values vary from 0 to ~441.67 linearly</remarks>
+/// 
+/// <remarks>This distance metric is commonly used for comparing colors in RGB space, though
+/// it doesn't account for perceptual differences in color.</remarks>
 /// </summary>
 public class EuclideanColorDistance : IColorDistance
 {
+    /// <inheritdoc/>
     public double CalculateDistance (Color c1, Color c2)
     {
         int rDiff = c1.R - c2.R;

+ 0 - 141
Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs

@@ -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<Color> BuildPalette (List<Color> colors, int maxColors)
-    {
-        if (colors == null || colors.Count == 0 || maxColors <= 0)
-        {
-            return new List<Color> ();
-        }
-
-        return MedianCut (colors, maxColors);
-    }
-
-    private List<Color> MedianCut (List<Color> colors, int maxColors)
-    {
-        var cubes = new List<List<Color>> () { 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<Color> 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<Color>, List<Color>) SplitCube (List<Color> 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<Color> 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<Color> 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<Color> 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);
-    }
-}

+ 106 - 0
Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs

@@ -0,0 +1,106 @@
+using Terminal.Gui;
+using Color = Terminal.Gui.Color;
+
+/// <summary>
+/// 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.
+/// </summary>
+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<Color> BuildPalette (List<Color> colors, int maxColors)
+    {
+        if (colors == null || colors.Count == 0 || maxColors <= 0)
+        {
+            return new ();
+        }
+
+        // Step 1: Build the histogram of colors (count occurrences)
+        Dictionary<Color, int> colorHistogram = new Dictionary<Color, int> ();
+
+        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<Color, int> mergedHistogram = MergeSimilarColors (colorHistogram, maxColors);
+
+        // Step 3: Sort the histogram by frequency (most frequent colors first)
+        List<Color> sortedColors = mergedHistogram.OrderByDescending (c => c.Value)
+                                                  .Take (maxColors) // Keep only the top `maxColors` colors
+                                                  .Select (c => c.Key)
+                                                  .ToList ();
+
+        return sortedColors;
+    }
+
+    /// <summary>
+    /// Merge colors in the histogram if they are within the threshold distance
+    /// </summary>
+    /// <param name="colorHistogram"></param>
+    /// <returns></returns>
+    private Dictionary<Color, int> MergeSimilarColors (Dictionary<Color, int> colorHistogram, int maxColors)
+    {
+        Dictionary<Color, int> mergedHistogram = new Dictionary<Color, int> ();
+
+        foreach (KeyValuePair<Color, int> 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;
+    }
+}

+ 147 - 0
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<Color> BuildPalette (List<Color> colors, int maxColors)
+    {
+        if (colors == null || colors.Count == 0 || maxColors <= 0)
+        {
+            return new ();
+        }
+
+        return MedianCut (colors, maxColors);
+    }
+
+    private List<Color> MedianCut (List<Color> colors, int maxColors)
+    {
+        List<List<Color>> cubes = new() { colors };
+
+        // Recursively split color regions
+        while (cubes.Count < maxColors)
+        {
+            var added = false;
+            cubes.Sort ((a, b) => Volume (a).CompareTo (Volume (b)));
+
+            List<Color> 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<Color> cube1, List<Color> 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<Color> 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<Color>, List<Color>) SplitCube (List<Color> 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<Color> cube1 = cube.Take (medianIndex).ToList ();
+        List<Color> cube2 = cube.Skip (medianIndex).ToList ();
+
+        return (cube1, cube2);
+    }
+
+    private (int, int) FindLargestRange (List<Color> 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<Color> 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<Color> 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);
+    }
+}

+ 118 - 0
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<Color> colors = new ();
+
+        // Act
+        List<Color> result = paletteBuilder.BuildPalette (colors, 256);
+
+        // Assert
+        Assert.Empty (result);
+    }
+
+    [Fact]
+    public void BuildPalette_MaxColorsZero_ReturnsEmptyPalette ()
+    {
+        // Arrange
+        var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50);
+        List<Color> colors = new() { new (255, 0), new (0, 255) };
+
+        // Act
+        List<Color> result = paletteBuilder.BuildPalette (colors, 0);
+
+        // Assert
+        Assert.Empty (result);
+    }
+
+    [Fact]
+    public void BuildPalette_SingleColorList_ReturnsSingleColor ()
+    {
+        // Arrange
+        var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50);
+        List<Color> colors = new() { new (255, 0), new (255, 0) };
+
+        // Act
+        List<Color> 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<Color> colors = new List<Color>
+        {
+            new (255, 0), // Red
+            new (250, 0), // Very close to Red
+            new (0, 255), // Green
+            new (0, 250) // Very close to Green
+        };
+
+        // Act
+        List<Color> 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<Color> colors = new()
+        {
+            new (255, 0), // Red
+            new (0, 255) // Green
+        };
+
+        // Act
+        List<Color> 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<Color> colors = new List<Color>
+        {
+            new (255, 0), // Red
+            new (254, 0), // Close to Red
+            new (0, 255), // Green
+            new (0, 254) // Close to Green
+        };
+
+        // Act
+        List<Color> 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);
+    }
+}