Switch to simpler and faster palette builder

This commit is contained in:
tznind
2024-09-23 20:31:05 +01:00
parent a7c65bf8b4
commit f07ab92dca
6 changed files with 388 additions and 145 deletions

View File

@@ -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)
{

View File

@@ -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;

View File

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

View 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;
}
}

View File

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

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