Browse Source

Simplify and speed up palette building

tznind 10 months ago
parent
commit
eaa5c0e555

+ 2 - 3
Terminal.Gui/Drawing/Quant/ColorQuantizer.cs

@@ -1,5 +1,4 @@
-using System.Collections.ObjectModel;
-using Terminal.Gui.Drawing.Quant;
+
 
 namespace Terminal.Gui;
 
@@ -29,7 +28,7 @@ public class ColorQuantizer
     /// <summary>
     /// Gets or sets the algorithm used to build the <see cref="Palette"/>.
     /// </summary>
-    public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new KMeansPaletteBuilder (new EuclideanColorDistance ()) ;
+    public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new MedianCutPaletteBuilder (new EuclideanColorDistance ()) ;
 
     public void BuildPalette (Color [,] pixels)
     {

+ 11 - 0
Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs

@@ -1,6 +1,17 @@
 namespace Terminal.Gui;
 
+/// <summary>
+/// Builds a palette of a given size for a given set of input colors.
+/// </summary>
 public interface IPaletteBuilder
 {
+    /// <summary>
+    /// Reduce the number of <paramref name="colors"/> to <paramref name="maxColors"/> (or less)
+    /// using an appropriate selection algorithm.
+    /// </summary>
+    /// <param name="colors">Color of every pixel in the image. Contains duplication in order
+    /// to support algorithms that weigh how common a color is.</param>
+    /// <param name="maxColors">The maximum number of colours that should be represented.</param>
+    /// <returns></returns>
     List<Color> BuildPalette (List<Color> colors, int maxColors);
 }

+ 0 - 154
Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs

@@ -1,154 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace Terminal.Gui.Drawing.Quant;
-
-    /// <summary>
-    /// <see cref="IPaletteBuilder"/> that works well for images with high contrast images
-    /// </summary>
-    public class KMeansPaletteBuilder : IPaletteBuilder
-    {
-        private readonly int maxIterations;
-        private readonly Random random = new Random ();
-        private readonly IColorDistance colorDistance;
-
-        public KMeansPaletteBuilder (IColorDistance distanceAlgorithm, int maxIterations = 100)
-        {
-            colorDistance = distanceAlgorithm;
-            this.maxIterations = maxIterations;
-        }
-
-        public List<Color> BuildPalette (List<Color> colors, int maxColors)
-        {
-            // Convert colors to vectors
-            List<ColorVector> colorVectors = colors.Select (c => new ColorVector (c.R, c.G, c.B)).ToList ();
-
-            // Perform K-Means Clustering
-            List<ColorVector> centroids = KMeans (colorVectors, maxColors);
-
-            // Convert centroids back to colors
-            return centroids.Select (v => new Color ((int)v.R, (int)v.G, (int)v.B)).ToList ();
-        }
-
-        private List<ColorVector> KMeans (List<ColorVector> colors, int k)
-        {
-            // Randomly initialize k centroids
-            List<ColorVector> centroids = InitializeCentroids (colors, k);
-
-            List<ColorVector> previousCentroids = new List<ColorVector> ();
-            int iterations = 0;
-
-            // Repeat until convergence or max iterations
-            while (!HasConverged (centroids, previousCentroids) && iterations < maxIterations)
-            {
-                previousCentroids = centroids.Select (c => new ColorVector (c.R, c.G, c.B)).ToList ();
-
-                // Assign each color to the nearest centroid
-                var clusters = AssignColorsToClusters (colors, centroids);
-
-                // Recompute centroids
-                centroids = RecomputeCentroids (clusters);
-
-                iterations++;
-            }
-
-            return centroids;
-        }
-
-        private List<ColorVector> InitializeCentroids (List<ColorVector> colors, int k)
-        {
-            return colors.OrderBy (c => random.Next ()).Take (k).ToList (); // Randomly select k initial centroids
-        }
-
-        private Dictionary<ColorVector, List<ColorVector>> AssignColorsToClusters (List<ColorVector> colors, List<ColorVector> centroids)
-        {
-            var clusters = centroids.ToDictionary (c => c, c => new List<ColorVector> ());
-
-            foreach (var color in colors)
-            {
-                // Find the nearest centroid using the injected IColorDistance implementation
-                var nearestCentroid = centroids.OrderBy (c => colorDistance.CalculateDistance (c.ToColor (), color.ToColor ())).First ();
-                clusters [nearestCentroid].Add (color);
-            }
-
-            return clusters;
-        }
-
-        private List<ColorVector> RecomputeCentroids (Dictionary<ColorVector, List<ColorVector>> clusters)
-        {
-            var newCentroids = new List<ColorVector> ();
-
-            foreach (var cluster in clusters)
-            {
-                if (cluster.Value.Count == 0)
-                {
-                    // Reinitialize the centroid with a random color if the cluster is empty
-                    newCentroids.Add (InitializeRandomCentroid ());
-                }
-                else
-                {
-                    // Recompute the centroid as the mean of the cluster's points
-                    double avgR = cluster.Value.Average (c => c.R);
-                    double avgG = cluster.Value.Average (c => c.G);
-                    double avgB = cluster.Value.Average (c => c.B);
-
-                    newCentroids.Add (new ColorVector (avgR, avgG, avgB));
-                }
-            }
-
-            return newCentroids;
-        }
-
-        private bool HasConverged (List<ColorVector> currentCentroids, List<ColorVector> previousCentroids)
-        {
-            // Skip convergence check for the first iteration
-            if (previousCentroids.Count == 0)
-            {
-                return false; // Can't check for convergence in the first iteration
-            }
-
-            // Check if the length of current and previous centroids are different
-            if (currentCentroids.Count != previousCentroids.Count)
-            {
-                return false; // They haven't converged if they don't have the same number of centroids
-            }
-
-            // Check if the centroids have changed between iterations using the injected distance algorithm
-            for (int i = 0; i < currentCentroids.Count; i++)
-            {
-                if (colorDistance.CalculateDistance (currentCentroids [i].ToColor (), previousCentroids [i].ToColor ()) > 1.0) // Use a larger threshold
-                {
-                    return false; // Centroids haven't converged yet if any of them have moved significantly
-                }
-            }
-
-            return true; // Centroids have converged if all distances are below the threshold
-        }
-
-        private ColorVector InitializeRandomCentroid ()
-        {
-            // Initialize a random centroid by picking random color values
-            return new ColorVector (random.Next (0, 256), random.Next (0, 256), random.Next (0, 256));
-        }
-
-        private class ColorVector
-        {
-            public double R { get; }
-            public double G { get; }
-            public double B { get; }
-
-            public ColorVector (double r, double g, double b)
-            {
-                R = r;
-                G = g;
-                B = b;
-            }
-
-            // Convert ColorVector back to Color for use with the IColorDistance interface
-            public Color ToColor ()
-            {
-                return new Color ((int)R, (int)G, (int)B);
-            }
-        }
-}

+ 87 - 102
Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs

@@ -1,138 +1,123 @@
 namespace Terminal.Gui;
-
 public class MedianCutPaletteBuilder : IPaletteBuilder
 {
-    public List<Color> BuildPalette (List<Color> colors, int maxColors)
+    private readonly IColorDistance _colorDistance;
+
+    public MedianCutPaletteBuilder (IColorDistance colorDistance)
     {
-        // Initial step: place all colors in one large box
-        List<ColorBox> boxes = new List<ColorBox> { new ColorBox (colors) };
+        _colorDistance = colorDistance;
+    }
 
-        // Keep splitting boxes until we have the desired number of colors
-        while (boxes.Count < maxColors)
+    public List<Color> BuildPalette (List<Color> colors, int maxColors)
+    {
+        if (colors == null || colors.Count == 0 || maxColors <= 0)
         {
-            // Find the box with the largest brightness range and split it
-            ColorBox boxToSplit = FindBoxWithLargestRange (boxes);
-
-            if (boxToSplit == null || boxToSplit.Colors.Count == 0)
-            {
-                break;
-            }
-
-            // Split the box into two smaller boxes, based on luminance
-            var splitBoxes = SplitBoxByLuminance (boxToSplit);
-            boxes.Remove (boxToSplit);
-            boxes.AddRange (splitBoxes);
+            return new List<Color> ();
         }
 
-        // Average the colors in each box to get the final palette
-        return boxes.Select (box => box.GetWeightedAverageColor ()).ToList ();
+        return MedianCut (colors, maxColors);
     }
 
-    // Find the box with the largest brightness range (based on luminance)
-    private ColorBox FindBoxWithLargestRange (List<ColorBox> boxes)
+    private List<Color> MedianCut (List<Color> colors, int maxColors)
     {
-        ColorBox largestRangeBox = null;
-        double largestRange = 0;
+        var cubes = new List<List<Color>> () { colors };
 
-        foreach (var box in boxes)
+        // Recursively split color regions
+        while (cubes.Count < maxColors)
         {
-            double range = box.GetBrightnessRange ();
-            if (range > largestRange)
+            bool added = false;
+            cubes.Sort ((a, b) => Volume (a).CompareTo (Volume (b)));
+
+            var largestCube = cubes.Last ();
+            cubes.RemoveAt (cubes.Count - 1);
+
+            var (cube1, cube2) = SplitCube (largestCube);
+
+            if (cube1.Any ())
+            {
+                cubes.Add (cube1);
+                added = true;
+            }
+
+            if (cube2.Any ())
             {
-                largestRange = range;
-                largestRangeBox = box;
+                cubes.Add (cube2);
+                added = true;
+            }
+
+            if (!added)
+            {
+                break;
             }
         }
 
-        return largestRangeBox;
+        // Calculate average color for each cube
+        return cubes.Select (AverageColor).Distinct().ToList ();
     }
 
-    // Split a box at the median point based on brightness (luminance)
-    private List<ColorBox> SplitBoxByLuminance (ColorBox box)
+    // Splits the cube based on the largest color component range
+    private (List<Color>, List<Color>) SplitCube (List<Color> cube)
     {
-        var sortedColors = box.Colors.OrderBy (c => GetBrightness (c)).ToList ();
+        var (component, range) = FindLargestRange (cube);
 
-        // Split the box at the median
-        int medianIndex = sortedColors.Count / 2;
+        // 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 lowerHalf = sortedColors.Take (medianIndex).ToList ();
-        var upperHalf = sortedColors.Skip (medianIndex).ToList ();
+        var medianIndex = cube.Count / 2;
+        var cube1 = cube.Take (medianIndex).ToList ();
+        var cube2 = cube.Skip (medianIndex).ToList ();
 
-        return new List<ColorBox>
-        {
-            new ColorBox(lowerHalf),
-            new ColorBox(upperHalf)
-        };
+        return (cube1, cube2);
     }
 
-    // Calculate the brightness (luminance) of a color
-    private static double GetBrightness (Color color)
+    private (int, int) FindLargestRange (List<Color> cube)
     {
-        // Luminance formula (standard)
-        return 0.299 * color.R + 0.587 * color.G + 0.114 * color.B;
+        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);
     }
 
-    // The ColorBox class to represent a subset of colors
-    public class ColorBox
+    private Color AverageColor (List<Color> cube)
     {
-        public List<Color> Colors { get; private set; }
+        var avgR = (byte)(cube.Average (c => c.R));
+        var avgG = (byte)(cube.Average (c => c.G));
+        var avgB = (byte)(cube.Average (c => c.B));
 
-        public ColorBox (List<Color> colors)
-        {
-            Colors = colors;
-        }
+        return new Color (avgR, avgG, avgB);
+    }
 
-        // Get the range of brightness (luminance) in this box
-        public double GetBrightnessRange ()
+    private int Volume (List<Color> cube)
+    {
+        if (cube == null || cube.Count == 0)
         {
-            double minBrightness = double.MaxValue, maxBrightness = double.MinValue;
-
-            foreach (var color in Colors)
-            {
-                double brightness = GetBrightness (color);
-                if (brightness < minBrightness)
-                {
-                    minBrightness = brightness;
-                }
-
-                if (brightness > maxBrightness)
-                {
-                    maxBrightness = brightness;
-                }
-            }
-
-            return maxBrightness - minBrightness;
+            // Return a volume of 0 if the cube is empty or null
+            return 0;
         }
 
-        // Calculate the average color in the box, weighted by brightness (darker colors have more weight)
-        public Color GetWeightedAverageColor ()
-        {
-            double totalR = 0, totalG = 0, totalB = 0;
-            double totalWeight = 0;
-
-            foreach (var color in Colors)
-            {
-                double brightness = GetBrightness (color);
-                double weight = 1.0 - brightness / 255.0; // Darker colors get more weight
-
-                totalR += color.R * weight;
-                totalG += color.G * weight;
-                totalB += color.B * weight;
-                totalWeight += weight;
-            }
+        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);
 
-            // Normalize by the total weight
-            totalR /= totalWeight;
-            totalG /= totalWeight;
-            totalB /= totalWeight;
-
-            return new Color ((int)totalR, (int)totalG, (int)totalB);
-        }
-
-        // Calculate brightness (luminance) of a color
-        private static double GetBrightness (Color color)
-        {
-            return 0.299 * color.R + 0.587 * color.G + 0.114 * color.B;
-        }
+        return (maxR - minR) * (maxG - minG) * (maxB - minB);
     }
-}
+}

+ 23 - 23
Terminal.Gui/Drawing/SixelEncoder.cs

@@ -7,6 +7,29 @@ namespace Terminal.Gui;
 /// </summary>
 public class SixelEncoder
 {
+    /*
+
+    A sixel is a column of 6 pixels - with a width of 1 pixel
+
+    Column controlled by one sixel character:
+      [ ]  - Bit 0 (top-most pixel)
+      [ ]  - Bit 1
+      [ ]  - Bit 2
+      [ ]  - Bit 3
+      [ ]  - Bit 4
+      [ ]  - Bit 5 (bottom-most pixel)
+
+   Special Characters
+       The '-' acts like '\n'. It moves the drawing cursor
+       to beginning of next line
+
+       The '$' acts like the <Home> key.  It moves drawing
+       cursor back to beginning of the current line
+       e.g. to draw more color layers.
+
+   */
+
+
     /// <summary>
     /// Gets or sets the quantizer responsible for building a representative
     /// limited color palette for images and for mapping novel colors in
@@ -39,29 +62,6 @@ public class SixelEncoder
         return start + defaultRatios + completeStartSequence + noScaling + fillArea + pallette + pixelData + terminator;
     }
 
-
-    /*
-
-        A sixel is a column of 6 pixels - with a width of 1 pixel
-
-     Column controlled by one sixel character:
-       [ ]  - Bit 0 (top-most pixel)
-       [ ]  - Bit 1
-       [ ]  - Bit 2
-       [ ]  - Bit 3
-       [ ]  - Bit 4
-       [ ]  - Bit 5 (bottom-most pixel)
-
-    Special Characters
-        The '-' acts like '\n'. It moves the drawing cursor
-        to beginning of next line
-        
-        The '$' acts like the <Home> key.  It moves drawing
-        cursor back to beginning of the current line 
-        e.g. to draw more color layers.
-        
-    */
-
     /**
      * This method is adapted from
      * https://github.com/jerch/node-sixel/

+ 4 - 0
UnitTests/Drawing/SixelEncoderTests.cs

@@ -37,6 +37,10 @@ public class SixelEncoderTests
         var encoder = new SixelEncoder (); // Assuming SixelEncoder is the class that contains the EncodeSixel method
         string result = encoder.EncodeSixel (pixels);
 
+        // Since image is only red we should only have 1 color definition
+        Color c1 = Assert.Single (encoder.Quantizer.Palette);
+
+        Assert.Equal (new Color(255,0,0),c1);
 
         Assert.Equal (expected, result);
     }