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);
+ }
+}