mirror of
https://github.com/gui-cs/Terminal.Gui.git
synced 2025-12-29 01:07:58 +01:00
Switch to simpler and faster palette builder
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs
Normal file
106
Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs
Normal file
118
UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user