diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index 1529a3eea..4b32e1669 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/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 /// /// Gets or sets the algorithm used to build the . /// - public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new KMeansPaletteBuilder (new EuclideanColorDistance ()) ; + public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new MedianCutPaletteBuilder (new EuclideanColorDistance ()) ; public void BuildPalette (Color [,] pixels) { diff --git a/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs index e72de3769..999297cff 100644 --- a/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs +++ b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs @@ -1,6 +1,17 @@ namespace Terminal.Gui; +/// +/// Builds a palette of a given size for a given set of input colors. +/// public interface IPaletteBuilder { + /// + /// Reduce the number of to (or less) + /// using an appropriate selection algorithm. + /// + /// Color of every pixel in the image. Contains duplication in order + /// to support algorithms that weigh how common a color is. + /// The maximum number of colours that should be represented. + /// List BuildPalette (List colors, int maxColors); } diff --git a/Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs deleted file mode 100644 index 0cf8bb0eb..000000000 --- a/Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Terminal.Gui.Drawing.Quant; - - /// - /// that works well for images with high contrast images - /// - 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 BuildPalette (List colors, int maxColors) - { - // Convert colors to vectors - List colorVectors = colors.Select (c => new ColorVector (c.R, c.G, c.B)).ToList (); - - // Perform K-Means Clustering - List 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 KMeans (List colors, int k) - { - // Randomly initialize k centroids - List centroids = InitializeCentroids (colors, k); - - List previousCentroids = new List (); - 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 InitializeCentroids (List colors, int k) - { - return colors.OrderBy (c => random.Next ()).Take (k).ToList (); // Randomly select k initial centroids - } - - private Dictionary> AssignColorsToClusters (List colors, List centroids) - { - var clusters = centroids.ToDictionary (c => c, c => new List ()); - - 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 RecomputeCentroids (Dictionary> clusters) - { - var newCentroids = new List (); - - 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 currentCentroids, List 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); - } - } -} diff --git a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs index 9a49c9a64..cdafdfaa3 100644 --- a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs +++ b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs @@ -1,138 +1,123 @@ namespace Terminal.Gui; - public class MedianCutPaletteBuilder : IPaletteBuilder { + private readonly IColorDistance _colorDistance; + + public MedianCutPaletteBuilder (IColorDistance colorDistance) + { + _colorDistance = colorDistance; + } + public List BuildPalette (List colors, int maxColors) { - // Initial step: place all colors in one large box - List boxes = new List { new ColorBox (colors) }; - - // Keep splitting boxes until we have the desired number of colors - while (boxes.Count < maxColors) + if (colors == null || colors.Count == 0 || maxColors <= 0) { - // Find the box with the largest brightness range and split it - ColorBox boxToSplit = FindBoxWithLargestRange (boxes); + return new List (); + } - if (boxToSplit == null || boxToSplit.Colors.Count == 0) + 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); + + var (cube1, cube2) = SplitCube (largestCube); + + if (cube1.Any ()) + { + cubes.Add (cube1); + added = true; + } + + if (cube2.Any ()) + { + cubes.Add (cube2); + added = true; + } + + if (!added) { break; } - - // Split the box into two smaller boxes, based on luminance - var splitBoxes = SplitBoxByLuminance (boxToSplit); - boxes.Remove (boxToSplit); - boxes.AddRange (splitBoxes); } - // Average the colors in each box to get the final palette - return boxes.Select (box => box.GetWeightedAverageColor ()).ToList (); + // Calculate average color for each cube + return cubes.Select (AverageColor).Distinct().ToList (); } - // Find the box with the largest brightness range (based on luminance) - private ColorBox FindBoxWithLargestRange (List boxes) + // Splits the cube based on the largest color component range + private (List, List) SplitCube (List cube) { - ColorBox largestRangeBox = null; - double largestRange = 0; + var (component, range) = FindLargestRange (cube); - foreach (var box in boxes) + // Sort by the largest color range component (either R, G, or B) + cube.Sort ((c1, c2) => component switch { - double range = box.GetBrightnessRange (); - if (range > largestRange) - { - largestRange = range; - largestRangeBox = box; - } - } + 0 => c1.R.CompareTo (c2.R), + 1 => c1.G.CompareTo (c2.G), + 2 => c1.B.CompareTo (c2.B), + _ => 0 + }); - return largestRangeBox; + var medianIndex = cube.Count / 2; + var cube1 = cube.Take (medianIndex).ToList (); + var cube2 = cube.Skip (medianIndex).ToList (); + + return (cube1, cube2); } - // Split a box at the median point based on brightness (luminance) - private List SplitBoxByLuminance (ColorBox box) + private (int, int) FindLargestRange (List cube) { - var sortedColors = box.Colors.OrderBy (c => GetBrightness (c)).ToList (); + 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); - // Split the box at the median - int medianIndex = sortedColors.Count / 2; + var rangeR = maxR - minR; + var rangeG = maxG - minG; + var rangeB = maxB - minB; - var lowerHalf = sortedColors.Take (medianIndex).ToList (); - var upperHalf = sortedColors.Skip (medianIndex).ToList (); - - return new List - { - new ColorBox(lowerHalf), - new ColorBox(upperHalf) - }; + if (rangeR >= rangeG && rangeR >= rangeB) return (0, rangeR); + if (rangeG >= rangeR && rangeG >= rangeB) return (1, rangeG); + return (2, rangeB); } - // Calculate the brightness (luminance) of a color - private static double GetBrightness (Color color) + private Color AverageColor (List cube) { - // Luminance formula (standard) - return 0.299 * color.R + 0.587 * color.G + 0.114 * color.B; + 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); } - // The ColorBox class to represent a subset of colors - public class ColorBox + private int Volume (List cube) { - public List Colors { get; private set; } - - public ColorBox (List colors) + if (cube == null || cube.Count == 0) { - Colors = colors; + // Return a volume of 0 if the cube is empty or null + return 0; } - // Get the range of brightness (luminance) in this box - public double GetBrightnessRange () - { - double minBrightness = double.MaxValue, maxBrightness = double.MinValue; + 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); - foreach (var color in Colors) - { - double brightness = GetBrightness (color); - if (brightness < minBrightness) - { - minBrightness = brightness; - } - - if (brightness > maxBrightness) - { - maxBrightness = brightness; - } - } - - return maxBrightness - minBrightness; - } - - // 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; - } - - // 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); } -} \ No newline at end of file +} diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index dfe9f63a4..c8a50a54b 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -7,6 +7,29 @@ namespace Terminal.Gui; /// 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 key. It moves drawing + cursor back to beginning of the current line + e.g. to draw more color layers. + + */ + + /// /// 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 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/ diff --git a/UnitTests/Drawing/SixelEncoderTests.cs b/UnitTests/Drawing/SixelEncoderTests.cs index 6f36e8990..3c8042710 100644 --- a/UnitTests/Drawing/SixelEncoderTests.cs +++ b/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); }