mirror of
https://github.com/gui-cs/Terminal.Gui.git
synced 2025-12-29 09:18:01 +01:00
Simplify and speed up palette building
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user