Simplify and speed up palette building

This commit is contained in:
tznind
2024-09-15 15:36:43 +01:00
parent cbef6c591a
commit eaa5c0e555
6 changed files with 129 additions and 284 deletions

View File

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

View File

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

View File

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

View File

@@ -1,138 +1,123 @@
namespace Terminal.Gui;
public class MedianCutPaletteBuilder : IPaletteBuilder
{
private readonly IColorDistance _colorDistance;
public MedianCutPaletteBuilder (IColorDistance colorDistance)
{
_colorDistance = colorDistance;
}
public List<Color> BuildPalette (List<Color> colors, int maxColors)
{
// Initial step: place all colors in one large box
List<ColorBox> boxes = new List<ColorBox> { 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<Color> ();
}
if (boxToSplit == null || boxToSplit.Colors.Count == 0)
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);
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<ColorBox> boxes)
// Splits the cube based on the largest color component range
private (List<Color>, List<Color>) SplitCube (List<Color> 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<ColorBox> SplitBoxByLuminance (ColorBox box)
private (int, int) FindLargestRange (List<Color> 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<ColorBox>
{
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<Color> 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<Color> cube)
{
public List<Color> Colors { get; private set; }
public ColorBox (List<Color> 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);
}
}
}

View File

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

View File

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