From 4d1d740e17b8a16339dd3595a16537ff496e3210 Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 9 Sep 2024 20:39:25 +0100 Subject: [PATCH 01/62] Initial exploration of how to sixel encode --- .../Application/Application.Driver.cs | 2 + Terminal.Gui/ConsoleDrivers/NetDriver.cs | 13 ++++ Terminal.Gui/Drawing/SixelEncoder.cs | 70 +++++++++++++++++++ UICatalog/Scenarios/Images.cs | 39 ++++++++++- UnitTests/Drawing/SixelEncoderTests.cs | 44 ++++++++++++ 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 Terminal.Gui/Drawing/SixelEncoder.cs create mode 100644 UnitTests/Drawing/SixelEncoderTests.cs diff --git a/Terminal.Gui/Application/Application.Driver.cs b/Terminal.Gui/Application/Application.Driver.cs index f15bd80539..4894482531 100644 --- a/Terminal.Gui/Application/Application.Driver.cs +++ b/Terminal.Gui/Application/Application.Driver.cs @@ -26,4 +26,6 @@ public static partial class Application // Driver abstractions /// [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] public static string ForceDriver { get; set; } = string.Empty; + + public static string Sixel; } diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index fcd7316e22..6914bbef4d 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -1020,6 +1020,19 @@ public override void UpdateScreen () SetCursorPosition (lastCol, row); Console.Write (output); } + + /* + // Hard-coded sixel content from the file + string knownGood = "\u001bP0;0;0q\"1;1;60;23#0;2;0;0;0#1;2;99;36;0#2;2;96;40;0#3;2;98;39;0#4;2;95;42;0#5;2;93;46;0#6;2;91;50;0#7;2;92;49;0#8;2;94;44;0#9;2;0;100;7#10;2;87;56;0#11;2;86;58;0#12;2;84;61;0#13;2;85;60;0#14;2;90;52;0#15;2;91;50;0#16;2;88;54;0#17;2;81;65;0#18;2;80;67;0#19;2;82;64;0#20;2;78;71;0#21;2;79;70;0#22;2;76;74;0#23;2;75;76;0#24;2;75;76;0#25;2;75;76;0#26;2;73;78;0#27;2;73;78;0#28;2;74;77;0#29;2;74;77;0#30;2;74;77;0#31;2;75;77;0#32;2;74;78;0#33;2;74;78;0#34;2;74;77;0#35;2;72;80;0#36;2;71;81;0#37;2;72;81;0#38;2;71;81;0#39;2;72;81;0#40;2;73;78;0#41;2;73;78;0#42;2;73;79;0#43;2;73;79;0#44;2;73;80;0#45;2;73;79;0#46;2;72;80;0#47;2;73;80;0#48;2;72;80;0#49;2;73;79;0#50;2;69;85;0#51;2;68;86;0#52;2;69;86;0#53;2;68;86;0#54;2;69;86;0#55;2;68;87;0#56;2;68;87;0#57;2;68;87;0#58;2;68;87;0#59;2;68;87;0#60;2;71;82;0#61;2;71;82;0#62;2;71;82;0#63;2;71;82;0#64;2;71;82;0#65;2;71;83;0#66;2;70;83;0#67;2;70;84;0#68;2;69;84;0#69;2;70;84;0#70;2;70;84;0#71;2;70;84;0#72;2;71;83;0#73;2;71;83;0#74;2;69;85;0#75;2;69;85;0#76;2;69;85;0#77;2;70;85;0#78;2;69;85;0#79;2;69;86;0#80;2;69;85;0#81;2;62;96;0#82;2;62;96;0#83;2;62;96;0#84;2;62;97;0#85;2;62;97;0#86;2;62;96;0#87;2;61;98;0#88;2;62;97;0#89;2;61;98;0#90;2;62;98;0#91;2;62;97;0#92;2;61;98;0#93;2;62;98;0#94;2;61;98;0#95;2;60;99;0#96;2;61;99;0#97;2;60;99;0#98;2;61;99;0#99;2;60;100;0#100;2;60;100;0#101;2;60;100;0#102;2;62;98;0#103;2;65;90;0#104;2;67;88;0#105;2;67;89;0#106;2;67;89;0#107;2;67;89;0#108;2;67;89;0#109;2;67;88;0#110;2;68;88;0#111;2;67;88;0#112;2;66;89;0#113;2;66;90;0#114;2;66;90;0#115;2;67;89;0#116;2;66;90;0#117;2;66;90;0#118;2;66;91;0#119;2;67;89;0#120;2;65;91;0#121;2;65;91;0#122;2;65;92;0#123;2;65;92;0#124;2;64;93;0#125;2;64;94;0#126;2;64;94;0#127;2;64;93;0#128;2;65;93;0#129;2;64;93;0#130;2;64;93;0#131;2;65;92;0#132;2;66;91;0#133;2;63;95;0#134;2;64;94;0#135;2;64;94;0#136;2;64;95;0#137;2;63;95;0#138;2;63;95;0#139;2;63;95;0#140;2;64;95;0#141;2;63;96;0#142;2;63;96;0#143;2;64;95;0#144;2;75;75;0#4~{w#7??B^}o#14F^_#10BFo_#13F~o#19?BN{#0{Ez|llJ}KsSsKw#37@FW#70BEw_#107?@Mo#121B]w#135@Mw#88BMo_$#8?BCo#6???@#15Nw_#16^{w#11N^w#12?N~{o#17BB#9wCAQQs?oGGGo#45F{o#65F{_#55?@Fw_#132?C#129@Co#141@Eo#87@#98N^~~$#5??BN~{_#18!17?@#22!5?@BB_#32B#26B#48?AG_#74?@F[o#110EO#120??_#124BMo#83@Ko$#23!33?B#72!7?W#52?AG#112?@Nw-#4@F^~w#5FN{o#15?F]o#16N~[_#13?D~w_#19@?{wo#18?Gw_#20Gw#22www_#32Fo_#37DMo#72C_#52@W_#110G_#121@Nw_#83?@^w_$#2EW_#8?Fwo#7BN~w_#10???b]o#12??F^}{#9@@AAAB?@AAA@#23[w#45F]o#65@Mo#74B]_#107@FWo#129?FW_#88??F[_$#3w_#14!9?@No#11??@Ny#0!4?BAEDDDCFEDDDEB#26?G#48@Io#70@J[_#55F]o#112FMo#135?BN}_#87?AO$#17!26?Gwo#21?Wo#124!21?CO#98???@N-#0MIyAyYmggWowGggwGgggGggG}I}wGgggWgggGw}A}??wKuyYYU{wGw?wG}I}$#1o_#4?@@@#8@A#7?@FFE#14?BE#10?BF#11F#13FFC#19?@@@C#18?@F#21FC#22?BFFE#23@#32@?o#37AA#70?@#74@@#55@#110@#107BE#121@F}#124@#135F#141@#83@#88@$#3@O#5!5?DFEG#15?@FC#16@FC#12!4?BF#17???BFE#20??BFC#144??@#45??@Nw#65@B#112!6?@C#129?@E$#9?CC{CcOOO_??oOO?oOOOoOOo?s??oOOO_OOOo??{!4?oGCccg??o???o?s$#2?@@#48!39?DC#120!8?A-#1^^OO#3OO#2O#4OOO#8O#5OO#7O^O#15O#14OO#10??O#11OO#13OO#12OOO#19OWO#18?OO#21O#20OO#22OOO#23O#32O#26O#45WO#48O#65OO#70O#74OO#55O#110O#112@O#121OO#129O#135O$#0??NGNK!4INNGN?NGN?NGNNGNGNNGN?NGGIIGNNGNNHNCLJJJGNFKJIJGNGN$#9???F?B!4D??F???F???F??F?F??F???FFDDF??F??E?BACCCF??BCCCF?F$#16!18?NOO#17!9?F?O#52!18?G#107??O-\u001b\\"; + + Application.Sixel = knownGood; + */ + + if (!string.IsNullOrWhiteSpace(Application.Sixel)) + { + Console.SetCursorPosition (0,0); + Console.Write (Application.Sixel); + } } SetCursorPosition (0, 0); diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs new file mode 100644 index 0000000000..ed8a0c3890 --- /dev/null +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -0,0 +1,70 @@ +using System.Reflection.Metadata; + +namespace Terminal.Gui; + +/// +/// Encodes a images into the sixel console image output format. +/// +public class SixelEncoder +{ + + + /// + /// Encode the given bitmap into sixel encoding + /// + /// + /// + public string EncodeSixel (Color [,] pixels) + { + + const string start = "\u001bP"; // Start sixel sequence + + const string defaultRatios = "0;0;0"; // Defaults for aspect ratio and grid size + const string completeStartSequence = "q"; // Signals beginning of sixel image data + const string noScaling = "\"1;1;"; // no scaling factors (1x1); + + string fillArea = GetFillArea (pixels); + + string pallette = GetColorPallette (pixels, out var dictionary); + + const string pixelData = + "~~~~$-" + + // First 6 rows of red pixels + "~~~~$-"; // Next 6 rows of red pixels + + const string terminator = "\u001b\\"; // End sixel sequence + + return start + defaultRatios + completeStartSequence + noScaling + fillArea + pallette + pixelData + terminator; + } + + private string GetColorPallette (Color [,] pixels, out Dictionary dictionary) + { + + dictionary = new Dictionary + { + {new Color(255,0,0),0} + }; + + // Red color definition in the format "#;;;;" - 2 means RGB. The values range 0 to 100 + return "#0;2;100;0;0"; + } + + private string GetFillArea (Color [,] pixels) + { + int widthInChars = GetWidthInChars (pixels); + int heightInChars = GetHeightInChars (pixels); + + return $"{widthInChars};{heightInChars}"; + } + + private int GetHeightInChars (Color [,] pixels) + { + // TODO + return 2; + } + + private int GetWidthInChars (Color [,] pixels) + { + return 3; + } +} \ No newline at end of file diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 4faab9b8b4..59590708c9 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -48,7 +48,7 @@ public override void Main () var btnOpenImage = new Button { X = Pos.Right (cbUseTrueColor) + 2, Y = 0, Text = "Open Image" }; win.Add (btnOpenImage); - + var imageView = new ImageView { X = 0, Y = Pos.Bottom (lblDriverName), Width = Dim.Fill (), Height = Dim.Fill () @@ -103,11 +103,16 @@ public override void Main () Application.Refresh (); }; + var btnSixel = new Button () { X = Pos.Right (btnOpenImage) + 2, Y = 0, Text = "Output Sixel" }; + btnSixel.Accept += (s, e) => { imageView.OutputSixel ();}; + win.Add (btnSixel); + Application.Run (win); win.Dispose (); Application.Shutdown (); } + private class ImageView : View { private readonly ConcurrentDictionary _cache = new (); @@ -155,5 +160,37 @@ internal void SetImage (Image image) _fullResImage = image; SetNeedsDisplay (); } + + public void OutputSixel () + { + if (_fullResImage == null) + { + return; + } + + var encoder = new SixelEncoder (); + + var encoded = encoder.EncodeSixel (ConvertToColorArray (_fullResImage)); + + Application.Sixel = encoded; + } + public static Color [,] ConvertToColorArray (Image image) + { + int width = image.Width; + int height = image.Height; + Color [,] colors = new Color [width, height]; + + // Loop through each pixel and convert Rgba32 to System.Drawing.Color + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + var pixel = image [x, y]; + colors [x, y] = new Color (pixel.A, pixel.R, pixel.G, pixel.B); // Convert Rgba32 to System.Drawing.Color + } + } + + return colors; + } } } diff --git a/UnitTests/Drawing/SixelEncoderTests.cs b/UnitTests/Drawing/SixelEncoderTests.cs new file mode 100644 index 0000000000..6f36e8990e --- /dev/null +++ b/UnitTests/Drawing/SixelEncoderTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Color = Terminal.Gui.Color; + +namespace UnitTests.Drawing; + +public class SixelEncoderTests +{ + + [Fact] + public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel () + { + + var expected = "\u001bP" + // Start sixel sequence + "0;0;0" + // Defaults for aspect ratio and grid size + "q" + // Signals beginning of sixel image data + "\"1;1;3;2" + // no scaling factors (1x1) and filling 3 runes horizontally and 2 vertically + "#0;2;100;0;0" + // Red color definition in the format "#;;;;" - 2 means RGB. The values range 0 to 100 + "~~~~$-" + // First 6 rows of red pixels + "~~~~$-" + // Next 6 rows of red pixels + "\u001b\\"; // End sixel sequence + + // Arrange: Create a 12x12 bitmap filled with red + var pixels = new Color [12, 12]; + for (int x = 0; x < 12; x++) + { + for (int y = 0; y < 12; y++) + { + pixels [x, y] = new Color(255,0,0); + } + } + + // Act: Encode the image + var encoder = new SixelEncoder (); // Assuming SixelEncoder is the class that contains the EncodeSixel method + string result = encoder.EncodeSixel (pixels); + + + Assert.Equal (expected, result); + } + +} From f0960624c1124befb7da8db2e003372d88d42268 Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 9 Sep 2024 21:28:19 +0100 Subject: [PATCH 02/62] Add ColorQuantizer --- Terminal.Gui/Drawing/ColorQuantizer.cs | 78 ++++++++++++++++++++++++++ Terminal.Gui/Drawing/SixelEncoder.cs | 27 ++++++--- 2 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 Terminal.Gui/Drawing/ColorQuantizer.cs diff --git a/Terminal.Gui/Drawing/ColorQuantizer.cs b/Terminal.Gui/Drawing/ColorQuantizer.cs new file mode 100644 index 0000000000..945e1daf4d --- /dev/null +++ b/Terminal.Gui/Drawing/ColorQuantizer.cs @@ -0,0 +1,78 @@ +namespace Terminal.Gui; + +/// +/// Translates colors in an image into a Palette of up to 256 colors. +/// +public class ColorQuantizer +{ + private Dictionary colorFrequency; + public List Palette; + private const int MaxColors = 256; + + public ColorQuantizer () + { + colorFrequency = new Dictionary (); + Palette = new List (); + } + + public void BuildColorPalette (Color [,] pixels) + { + int width = pixels.GetLength (0); + int height = pixels.GetLength (1); + + // Count the frequency of each color + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + Color color = pixels [x, y]; + if (colorFrequency.ContainsKey (color)) + { + colorFrequency [color]++; + } + else + { + colorFrequency [color] = 1; + } + } + } + + // Create a sorted list of colors based on frequency + var sortedColors = colorFrequency.OrderByDescending (kvp => kvp.Value).ToList (); + + // Build the Palette with the most frequent colors up to MaxColors + Palette = sortedColors.Take (MaxColors).Select (kvp => kvp.Key).ToList (); + + + } + + public int GetNearestColor (Color toTranslate) + { + // Simple nearest color matching based on Euclidean distance in RGB space + double minDistance = double.MaxValue; + int nearestIndex = 0; + + for (var index = 0; index < Palette.Count; index++) + { + Color color = Palette [index]; + double distance = ColorDistance (color, toTranslate); + + if (distance < minDistance) + { + minDistance = distance; + nearestIndex = index; + } + } + + return nearestIndex; + } + + private double ColorDistance (Color c1, Color c2) + { + // Euclidean distance in RGB space + int rDiff = c1.R - c2.R; + int gDiff = c1.G - c2.G; + int bDiff = c1.B - c2.B; + return Math.Sqrt (rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); + } +} \ No newline at end of file diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index ed8a0c3890..4e95e846c1 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -7,8 +7,6 @@ namespace Terminal.Gui; /// public class SixelEncoder { - - /// /// Encode the given bitmap into sixel encoding /// @@ -37,16 +35,27 @@ public string EncodeSixel (Color [,] pixels) return start + defaultRatios + completeStartSequence + noScaling + fillArea + pallette + pixelData + terminator; } - private string GetColorPallette (Color [,] pixels, out Dictionary dictionary) + private string GetColorPallette (Color [,] pixels, out ColorQuantizer quantizer) { + quantizer = new ColorQuantizer (); + quantizer.BuildColorPalette (pixels); - dictionary = new Dictionary - { - {new Color(255,0,0),0} - }; - // Red color definition in the format "#;;;;" - 2 means RGB. The values range 0 to 100 - return "#0;2;100;0;0"; + // Color definitions in the format "#;;;;" - For type the 2 means RGB. The values range 0 to 100 + + StringBuilder paletteSb = new StringBuilder (); + + for (int i = 0; i < quantizer.Palette.Count; i++) + { + var color = quantizer.Palette [i]; + paletteSb.AppendFormat ("#{0};2;{1};{2};{3}", + i, + color.R * 100 / 255, + color.G * 100 / 255, + color.B * 100 / 255); + } + + return paletteSb.ToString (); } private string GetFillArea (Color [,] pixels) From 943fa11230c9698d2d5e0aea74618f1c2145502f Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 9 Sep 2024 23:02:34 +0100 Subject: [PATCH 03/62] Work on SixelEncoder --- Terminal.Gui/Drawing/SixelEncoder.cs | 152 ++++++++++++++++++++++++--- 1 file changed, 139 insertions(+), 13 deletions(-) diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index 4e95e846c1..2063f00234 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -1,6 +1,4 @@ -using System.Reflection.Metadata; - -namespace Terminal.Gui; +namespace Terminal.Gui; /// /// Encodes a images into the sixel console image output format. @@ -23,18 +21,144 @@ public string EncodeSixel (Color [,] pixels) string fillArea = GetFillArea (pixels); - string pallette = GetColorPallette (pixels, out var dictionary); + string pallette = GetColorPallette (pixels, out var quantizer); - const string pixelData = - "~~~~$-" - + // First 6 rows of red pixels - "~~~~$-"; // Next 6 rows of red pixels + string pixelData = WriteSixel (pixels, quantizer); const string terminator = "\u001b\\"; // End sixel sequence 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) + */ + + private string WriteSixel (Color [,] pixels, ColorQuantizer quantizer) + { + StringBuilder sb = new StringBuilder (); + int height = pixels.GetLength (1); + int width = pixels.GetLength (0); + int n = 1; // Used for checking when to add the line terminator + + // Iterate over each row of the image + for (int y = 0; y < height; y++) + { + int p = y * width; + Color cachedColor = pixels [0, y]; + int cachedColorIndex = quantizer.GetNearestColor (cachedColor); + int count = 1; + int c = -1; + + // Iterate through each column in the row + for (int x = 0; x < width; x++) + { + Color color = pixels [x, y]; + int colorIndex = quantizer.GetNearestColor (color); + + if (colorIndex == cachedColorIndex) + { + count++; + } + else + { + // Output the cached color first + if (cachedColorIndex == -1) + { + c = 0x3f; // Key color or transparent + } + else + { + c = 0x3f + n; + sb.AppendFormat ("#{0}", cachedColorIndex); + } + + // If count is less than 3, we simply repeat the character + if (count < 3) + { + sb.Append ((char)c, count); + } + else + { + // RLE if count is greater than 3 + sb.AppendFormat ("!{0}{1}", count, (char)c); + } + + // Reset for the new color + count = 1; + cachedColorIndex = colorIndex; + } + } + + // Handle the last run of the color + if (c != -1 && count > 1) + { + if (cachedColorIndex == -1) + { + c = 0x3f; // Key color + } + else + { + sb.AppendFormat ("#{0}", cachedColorIndex); + } + + if (count < 3) + { + sb.Append ((char)c, count); + } + else + { + sb.AppendFormat ("!{0}{1}", count, (char)c); + } + } + + // Line terminator or separator depending on `n` + if (n == 32) + { + /* + 2. Line Separator (-): + + The line separator instructs the sixel renderer to move to the next row of sixels. + After a -, the renderer will start a new row from the leftmost column. This marks the end of one line of sixel data and starts a new line. + This ensures that the sixel data drawn after the separator appears below the previous row rather than overprinting it. + + Use case: When you want to start drawing a new line of sixels (e.g., after completing a row of sixel columns). + */ + + n = 1; + sb.Append ("-"); // Write sixel line separator + } + else + { + /* + *1. Line Terminator ($): + + The line terminator instructs the sixel renderer to return to the start of the current row but allows subsequent sixel characters to be overprinted on the same row. + This is used when you are working with multiple color layers or want to continue drawing in the same row but with a different color. + The $ allows you to overwrite sixel characters in the same vertical position by using different colors, effectively allowing you to combine colors on a per-sixel basis. + + Use case: When you need to draw multiple colors within the same vertical slice of 6 pixels. + */ + + n <<= 1; + sb.Append ("$"); // Write line terminator + } + } + + return sb.ToString (); + } + + + private string GetColorPallette (Color [,] pixels, out ColorQuantizer quantizer) { quantizer = new ColorQuantizer (); @@ -65,15 +189,17 @@ private string GetFillArea (Color [,] pixels) return $"{widthInChars};{heightInChars}"; } - private int GetHeightInChars (Color [,] pixels) { - // TODO - return 2; - } + // Height in pixels is equal to the number of rows in the pixel array + int height = pixels.GetLength (1); + // Each SIXEL character represents 6 pixels vertically + return (height + 5) / 6; // Equivalent to ceiling(height / 6) + } private int GetWidthInChars (Color [,] pixels) { - return 3; + // Width in pixels is equal to the number of columns in the pixel array + return pixels.GetLength (0); } } \ No newline at end of file From c6281ddddb58ac5c918b5c4f346631b15c815ed7 Mon Sep 17 00:00:00 2001 From: tznind Date: Wed, 11 Sep 2024 19:52:11 +0100 Subject: [PATCH 04/62] Build color palette using median cut instead of naive method --- Terminal.Gui/Drawing/ColorQuantizer.cs | 219 ++++++++++++++++++++++--- Terminal.Gui/Drawing/SixelEncoder.cs | 2 +- 2 files changed, 198 insertions(+), 23 deletions(-) diff --git a/Terminal.Gui/Drawing/ColorQuantizer.cs b/Terminal.Gui/Drawing/ColorQuantizer.cs index 945e1daf4d..6e1e4b2e5f 100644 --- a/Terminal.Gui/Drawing/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/ColorQuantizer.cs @@ -15,38 +15,24 @@ public ColorQuantizer () Palette = new List (); } - public void BuildColorPalette (Color [,] pixels) + public void BuildPalette (Color [,] pixels, IPaletteBuilder builder) { + List allColors = new List (); int width = pixels.GetLength (0); int height = pixels.GetLength (1); - // Count the frequency of each color for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { - Color color = pixels [x, y]; - if (colorFrequency.ContainsKey (color)) - { - colorFrequency [color]++; - } - else - { - colorFrequency [color] = 1; - } + allColors.Add (pixels [x, y]); } } - // Create a sorted list of colors based on frequency - var sortedColors = colorFrequency.OrderByDescending (kvp => kvp.Value).ToList (); - - // Build the Palette with the most frequent colors up to MaxColors - Palette = sortedColors.Take (MaxColors).Select (kvp => kvp.Key).ToList (); - - + Palette = builder.BuildPalette(allColors,MaxColors); } - public int GetNearestColor (Color toTranslate) + public int GetNearestColor (Color toTranslate, IColorDistance distanceAlgorithm) { // Simple nearest color matching based on Euclidean distance in RGB space double minDistance = double.MaxValue; @@ -55,7 +41,7 @@ public int GetNearestColor (Color toTranslate) for (var index = 0; index < Palette.Count; index++) { Color color = Palette [index]; - double distance = ColorDistance (color, toTranslate); + double distance = distanceAlgorithm.CalculateDistance(color, toTranslate); if (distance < minDistance) { @@ -66,13 +52,202 @@ public int GetNearestColor (Color toTranslate) return nearestIndex; } +} + +public interface IPaletteBuilder +{ + List BuildPalette (List colors, int maxColors); +} - private double ColorDistance (Color c1, Color c2) +/// +/// Interface for algorithms that compute the relative distance between pairs of colors. +/// This is used for color matching to a limited palette, such as in Sixel rendering. +/// +public interface IColorDistance +{ + /// + /// Computes a similarity metric between two instances. + /// A larger value indicates more dissimilar colors, while a smaller value indicates more similar colors. + /// The metric is internally consistent for the given algorithm. + /// + /// The first color. + /// The second color. + /// A numeric value representing the distance between the two colors. + double CalculateDistance (Color c1, Color c2); +} + +/// +/// 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. +/// +public class EuclideanColorDistance : IColorDistance +{ + public double CalculateDistance (Color c1, Color c2) { - // Euclidean distance in RGB space int rDiff = c1.R - c2.R; int gDiff = c1.G - c2.G; int bDiff = c1.B - c2.B; return Math.Sqrt (rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); } +} + + +class MedianCutPaletteBuilder : IPaletteBuilder +{ + public List BuildPalette (List colors, int maxColors) + { + // Initial step: place all colors in one large box + List boxes = new List { new ColorBox (colors) }; + + // Keep splitting boxes until we have the desired number of colors + while (boxes.Count < maxColors) + { + // Find the box with the largest range and split it + ColorBox boxToSplit = FindBoxWithLargestRange (boxes); + + if (boxToSplit == null || boxToSplit.Colors.Count == 0) + { + break; + } + + // Split the box into two smaller boxes + var splitBoxes = SplitBox (boxToSplit); + boxes.Remove (boxToSplit); + boxes.AddRange (splitBoxes); + } + + // Average the colors in each box to get the final palette + return boxes.Select (box => box.GetAverageColor ()).ToList (); + } + + // Find the box with the largest color range (R, G, or B) + private ColorBox FindBoxWithLargestRange (List boxes) + { + ColorBox largestRangeBox = null; + int largestRange = 0; + + foreach (var box in boxes) + { + int range = box.GetColorRange (); + if (range > largestRange) + { + largestRange = range; + largestRangeBox = box; + } + } + + return largestRangeBox; + } + + // Split a box at the median point in its largest color channel + private List SplitBox (ColorBox box) + { + List result = new List (); + + // Find the color channel with the largest range (R, G, or B) + int channel = box.GetLargestChannel (); + var sortedColors = box.Colors.OrderBy (c => GetColorChannelValue (c, channel)).ToList (); + + // Split the box at the median + int medianIndex = sortedColors.Count / 2; + + var lowerHalf = sortedColors.Take (medianIndex).ToList (); + var upperHalf = sortedColors.Skip (medianIndex).ToList (); + + result.Add (new ColorBox (lowerHalf)); + result.Add (new ColorBox (upperHalf)); + + return result; + } + + // Helper method to get the value of a color channel (R = 0, G = 1, B = 2) + private static int GetColorChannelValue (Color color, int channel) + { + switch (channel) + { + case 0: return color.R; + case 1: return color.G; + case 2: return color.B; + default: throw new ArgumentException ("Invalid channel index"); + } + } + + // The ColorBox class to represent a subset of colors + public class ColorBox + { + public List Colors { get; private set; } + + public ColorBox (List colors) + { + Colors = colors; + } + + // Get the color channel with the largest range (0 = R, 1 = G, 2 = B) + public int GetLargestChannel () + { + int rRange = GetColorRangeForChannel (0); + int gRange = GetColorRangeForChannel (1); + int bRange = GetColorRangeForChannel (2); + + if (rRange >= gRange && rRange >= bRange) + { + return 0; + } + + if (gRange >= rRange && gRange >= bRange) + { + return 1; + } + + return 2; + } + + // Get the range of colors for a given channel (0 = R, 1 = G, 2 = B) + private int GetColorRangeForChannel (int channel) + { + int min = int.MaxValue, max = int.MinValue; + + foreach (var color in Colors) + { + int value = GetColorChannelValue (color, channel); + if (value < min) + { + min = value; + } + + if (value > max) + { + max = value; + } + } + + return max - min; + } + + // Get the overall color range across all channels (for finding the box to split) + public int GetColorRange () + { + int rRange = GetColorRangeForChannel (0); + int gRange = GetColorRangeForChannel (1); + int bRange = GetColorRangeForChannel (2); + + return Math.Max (rRange, Math.Max (gRange, bRange)); + } + + // Calculate the average color in the box + public Color GetAverageColor () + { + int totalR = 0, totalG = 0, totalB = 0; + + foreach (var color in Colors) + { + totalR += color.R; + totalG += color.G; + totalB += color.B; + } + + int count = Colors.Count; + return new Color (totalR / count, totalG / count, totalB / count); + } + } } \ No newline at end of file diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index 2063f00234..96b7c9d1f1 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -162,7 +162,7 @@ This is used when you are working with multiple color layers or want to continue private string GetColorPallette (Color [,] pixels, out ColorQuantizer quantizer) { quantizer = new ColorQuantizer (); - quantizer.BuildColorPalette (pixels); + quantizer.BuildPaletteUsingMedianCut (pixels); // Color definitions in the format "#;;;;" - For type the 2 means RGB. The values range 0 to 100 From b482306395a56c2019b75db3d6e710224ec5f855 Mon Sep 17 00:00:00 2001 From: tznind Date: Wed, 11 Sep 2024 19:55:45 +0100 Subject: [PATCH 05/62] Fix build errors --- Terminal.Gui/Drawing/SixelEncoder.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index 96b7c9d1f1..b0d5746b58 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -45,6 +45,8 @@ [ ] - Bit 4 private string WriteSixel (Color [,] pixels, ColorQuantizer quantizer) { + var distanceAlgorithm = new EuclideanColorDistance (); + StringBuilder sb = new StringBuilder (); int height = pixels.GetLength (1); int width = pixels.GetLength (0); @@ -55,7 +57,7 @@ private string WriteSixel (Color [,] pixels, ColorQuantizer quantizer) { int p = y * width; Color cachedColor = pixels [0, y]; - int cachedColorIndex = quantizer.GetNearestColor (cachedColor); + int cachedColorIndex = quantizer.GetNearestColor (cachedColor,distanceAlgorithm ); int count = 1; int c = -1; @@ -63,7 +65,7 @@ private string WriteSixel (Color [,] pixels, ColorQuantizer quantizer) for (int x = 0; x < width; x++) { Color color = pixels [x, y]; - int colorIndex = quantizer.GetNearestColor (color); + int colorIndex = quantizer.GetNearestColor (color,distanceAlgorithm); if (colorIndex == cachedColorIndex) { @@ -162,7 +164,7 @@ This is used when you are working with multiple color layers or want to continue private string GetColorPallette (Color [,] pixels, out ColorQuantizer quantizer) { quantizer = new ColorQuantizer (); - quantizer.BuildPaletteUsingMedianCut (pixels); + quantizer.BuildPalette (pixels,new MedianCutPaletteBuilder ()); // Color definitions in the format "#;;;;" - For type the 2 means RGB. The values range 0 to 100 From 30817654b87bc31e5044f79250a9ca974b20a591 Mon Sep 17 00:00:00 2001 From: tznind Date: Wed, 11 Sep 2024 20:27:09 +0100 Subject: [PATCH 06/62] Refactoring and comments --- Terminal.Gui/Drawing/ColorQuantizer.cs | 153 ++++++++++++++++++++++--- Terminal.Gui/Drawing/SixelEncoder.cs | 10 +- 2 files changed, 143 insertions(+), 20 deletions(-) diff --git a/Terminal.Gui/Drawing/ColorQuantizer.cs b/Terminal.Gui/Drawing/ColorQuantizer.cs index 6e1e4b2e5f..b438a2f39f 100644 --- a/Terminal.Gui/Drawing/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/ColorQuantizer.cs @@ -1,21 +1,38 @@ -namespace Terminal.Gui; +using System.Collections.ObjectModel; +using ColorHelper; + +namespace Terminal.Gui; /// /// Translates colors in an image into a Palette of up to 256 colors. /// public class ColorQuantizer { - private Dictionary colorFrequency; - public List Palette; - private const int MaxColors = 256; + /// + /// Gets the current colors in the palette based on the last call to + /// . + /// + public IReadOnlyCollection Palette { get; private set; } = new List (); - public ColorQuantizer () - { - colorFrequency = new Dictionary (); - Palette = new List (); - } + /// + /// Gets or sets the maximum number of colors to put into the . + /// Defaults to 256 (the maximum for sixel images). + /// + public int MaxColors { get; set; } = 256; + + /// + /// Gets or sets the algorithm used to map novel colors into existing + /// palette colors (closest match). Defaults to + /// + public IColorDistance DistanceAlgorithm { get; set; } = new CIE94ColorDistance (); - public void BuildPalette (Color [,] pixels, IPaletteBuilder builder) + /// + /// Gets or sets the algorithm used to build the . + /// Defaults to + /// + public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new MedianCutPaletteBuilder (); + + public void BuildPalette (Color [,] pixels) { List allColors = new List (); int width = pixels.GetLength (0); @@ -29,10 +46,10 @@ public void BuildPalette (Color [,] pixels, IPaletteBuilder builder) } } - Palette = builder.BuildPalette(allColors,MaxColors); + Palette = PaletteBuildingAlgorithm.BuildPalette(allColors,MaxColors); } - public int GetNearestColor (Color toTranslate, IColorDistance distanceAlgorithm) + public int GetNearestColor (Color toTranslate) { // Simple nearest color matching based on Euclidean distance in RGB space double minDistance = double.MaxValue; @@ -40,8 +57,8 @@ public int GetNearestColor (Color toTranslate, IColorDistance distanceAlgorithm) for (var index = 0; index < Palette.Count; index++) { - Color color = Palette [index]; - double distance = distanceAlgorithm.CalculateDistance(color, toTranslate); + Color color = Palette.ElementAt(index); + double distance = DistanceAlgorithm.CalculateDistance(color, toTranslate); if (distance < minDistance) { @@ -90,6 +107,114 @@ public double CalculateDistance (Color c1, Color c2) return Math.Sqrt (rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); } } +public abstract class LabColorDistance : IColorDistance +{ + // Reference white point for D65 illuminant (can be moved to constants) + private const double RefX = 95.047; + private const double RefY = 100.000; + private const double RefZ = 108.883; + + // Conversion from RGB to Lab + protected LabColor RgbToLab (Color c) + { + var xyz = ColorHelper.ColorConverter.RgbToXyz (new RGB (c.R, c.G, c.B)); + + // Normalize XYZ values by reference white point + double x = xyz.X / RefX; + double y = xyz.Y / RefY; + double z = xyz.Z / RefZ; + + // Apply the nonlinear transformation for Lab + x = (x > 0.008856) ? Math.Pow (x, 1.0 / 3.0) : (7.787 * x) + (16.0 / 116.0); + y = (y > 0.008856) ? Math.Pow (y, 1.0 / 3.0) : (7.787 * y) + (16.0 / 116.0); + z = (z > 0.008856) ? Math.Pow (z, 1.0 / 3.0) : (7.787 * z) + (16.0 / 116.0); + + // Calculate Lab values + double l = (116.0 * y) - 16.0; + double a = 500.0 * (x - y); + double b = 200.0 * (y - z); + + return new LabColor (l, a, b); + } + + // LabColor class encapsulating L, A, and B values + protected class LabColor + { + public double L { get; } + public double A { get; } + public double B { get; } + + public LabColor (double l, double a, double b) + { + L = l; + A = a; + B = b; + } + } + + /// + public abstract double CalculateDistance (Color c1, Color c2); +} + +/// +/// This is the simplest method to measure color difference in the CIE Lab color space. The Euclidean distance in Lab +/// space is more aligned with human perception than RGB space, as Lab attempts to model how humans perceive color differences. +/// +public class CIE76ColorDistance : LabColorDistance +{ + public override double CalculateDistance (Color c1, Color c2) + { + var lab1 = RgbToLab (c1); + var lab2 = RgbToLab (c2); + + // Euclidean distance in Lab color space + return Math.Sqrt (Math.Pow (lab1.L - lab2.L, 2) + Math.Pow (lab1.A - lab2.A, 2) + Math.Pow (lab1.B - lab2.B, 2)); + } +} + +/// +/// CIE94 improves on CIE76 by introducing adjustments for chroma (color intensity) and lightness. +/// This algorithm considers human visual perception more accurately by scaling differences in lightness and chroma. +/// It is better but slower than . +/// +public class CIE94ColorDistance : LabColorDistance +{ + // Constants for CIE94 formula (can be modified for different use cases like textiles or graphics) + private const double kL = 1.0; + private const double kC = 1.0; + private const double kH = 1.0; + + public override double CalculateDistance (Color first, Color second) + { + var lab1 = RgbToLab (first); + var lab2 = RgbToLab (second); + + // Delta L, A, B + double deltaL = lab1.L - lab2.L; + double deltaA = lab1.A - lab2.A; + double deltaB = lab1.B - lab2.B; + + // Chroma values for both colors + double c1 = Math.Sqrt (lab1.A * lab1.A + lab1.B * lab1.B); + double c2 = Math.Sqrt (lab2.A * lab2.A + lab2.B * lab2.B); + double deltaC = c1 - c2; + + // Delta H (calculated indirectly) + double deltaH = Math.Sqrt (Math.Pow (deltaA, 2) + Math.Pow (deltaB, 2) - Math.Pow (deltaC, 2)); + + // Scaling factors + double sL = 1.0; + double sC = 1.0 + 0.045 * c1; + double sH = 1.0 + 0.015 * c1; + + // CIE94 color difference formula + return Math.Sqrt ( + Math.Pow (deltaL / (kL * sL), 2) + + Math.Pow (deltaC / (kC * sC), 2) + + Math.Pow (deltaH / (kH * sH), 2) + ); + } +} class MedianCutPaletteBuilder : IPaletteBuilder diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index b0d5746b58..eb5d7c1c53 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -45,8 +45,6 @@ [ ] - Bit 4 private string WriteSixel (Color [,] pixels, ColorQuantizer quantizer) { - var distanceAlgorithm = new EuclideanColorDistance (); - StringBuilder sb = new StringBuilder (); int height = pixels.GetLength (1); int width = pixels.GetLength (0); @@ -57,7 +55,7 @@ private string WriteSixel (Color [,] pixels, ColorQuantizer quantizer) { int p = y * width; Color cachedColor = pixels [0, y]; - int cachedColorIndex = quantizer.GetNearestColor (cachedColor,distanceAlgorithm ); + int cachedColorIndex = quantizer.GetNearestColor (cachedColor ); int count = 1; int c = -1; @@ -65,7 +63,7 @@ private string WriteSixel (Color [,] pixels, ColorQuantizer quantizer) for (int x = 0; x < width; x++) { Color color = pixels [x, y]; - int colorIndex = quantizer.GetNearestColor (color,distanceAlgorithm); + int colorIndex = quantizer.GetNearestColor (color); if (colorIndex == cachedColorIndex) { @@ -164,7 +162,7 @@ This is used when you are working with multiple color layers or want to continue private string GetColorPallette (Color [,] pixels, out ColorQuantizer quantizer) { quantizer = new ColorQuantizer (); - quantizer.BuildPalette (pixels,new MedianCutPaletteBuilder ()); + quantizer.BuildPalette (pixels); // Color definitions in the format "#;;;;" - For type the 2 means RGB. The values range 0 to 100 @@ -173,7 +171,7 @@ private string GetColorPallette (Color [,] pixels, out ColorQuantizer quantizer) for (int i = 0; i < quantizer.Palette.Count; i++) { - var color = quantizer.Palette [i]; + var color = quantizer.Palette.ElementAt (i); paletteSb.AppendFormat ("#{0};2;{1};{2};{3}", i, color.R * 100 / 255, From e334bfd00defab783017505325d8ae68ee55761d Mon Sep 17 00:00:00 2001 From: tznind Date: Wed, 11 Sep 2024 21:00:36 +0100 Subject: [PATCH 07/62] Refactor and split into seperate files WIP --- Terminal.Gui/Drawing/ColorQuantizer.cs | 378 ------------------ .../Drawing/Quant/CIE76ColorDistance.cs | 17 + .../Drawing/Quant/CIE94ColorDistance.cs | 45 +++ Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 71 ++++ .../Drawing/Quant/EuclideanColorDistance.cs | 16 + Terminal.Gui/Drawing/Quant/IColorDistance.cs | 18 + Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs | 6 + .../Drawing/Quant/KMeansPaletteBuilder.cs | 154 +++++++ .../Drawing/Quant/LabColorDistance.cs | 52 +++ .../Drawing/Quant/MedianCutPaletteBuilder.cs | 161 ++++++++ Terminal.Gui/Drawing/SixelEncoder.cs | 30 +- UICatalog/Scenarios/Images.cs | 4 + 12 files changed, 563 insertions(+), 389 deletions(-) delete mode 100644 Terminal.Gui/Drawing/ColorQuantizer.cs create mode 100644 Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs create mode 100644 Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs create mode 100644 Terminal.Gui/Drawing/Quant/ColorQuantizer.cs create mode 100644 Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs create mode 100644 Terminal.Gui/Drawing/Quant/IColorDistance.cs create mode 100644 Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs create mode 100644 Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs create mode 100644 Terminal.Gui/Drawing/Quant/LabColorDistance.cs create mode 100644 Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs diff --git a/Terminal.Gui/Drawing/ColorQuantizer.cs b/Terminal.Gui/Drawing/ColorQuantizer.cs deleted file mode 100644 index b438a2f39f..0000000000 --- a/Terminal.Gui/Drawing/ColorQuantizer.cs +++ /dev/null @@ -1,378 +0,0 @@ -using System.Collections.ObjectModel; -using ColorHelper; - -namespace Terminal.Gui; - -/// -/// Translates colors in an image into a Palette of up to 256 colors. -/// -public class ColorQuantizer -{ - /// - /// Gets the current colors in the palette based on the last call to - /// . - /// - public IReadOnlyCollection Palette { get; private set; } = new List (); - - /// - /// Gets or sets the maximum number of colors to put into the . - /// Defaults to 256 (the maximum for sixel images). - /// - public int MaxColors { get; set; } = 256; - - /// - /// Gets or sets the algorithm used to map novel colors into existing - /// palette colors (closest match). Defaults to - /// - public IColorDistance DistanceAlgorithm { get; set; } = new CIE94ColorDistance (); - - /// - /// Gets or sets the algorithm used to build the . - /// Defaults to - /// - public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new MedianCutPaletteBuilder (); - - public void BuildPalette (Color [,] pixels) - { - List allColors = new List (); - int width = pixels.GetLength (0); - int height = pixels.GetLength (1); - - for (int x = 0; x < width; x++) - { - for (int y = 0; y < height; y++) - { - allColors.Add (pixels [x, y]); - } - } - - Palette = PaletteBuildingAlgorithm.BuildPalette(allColors,MaxColors); - } - - public int GetNearestColor (Color toTranslate) - { - // Simple nearest color matching based on Euclidean distance in RGB space - double minDistance = double.MaxValue; - int nearestIndex = 0; - - for (var index = 0; index < Palette.Count; index++) - { - Color color = Palette.ElementAt(index); - double distance = DistanceAlgorithm.CalculateDistance(color, toTranslate); - - if (distance < minDistance) - { - minDistance = distance; - nearestIndex = index; - } - } - - return nearestIndex; - } -} - -public interface IPaletteBuilder -{ - List BuildPalette (List colors, int maxColors); -} - -/// -/// Interface for algorithms that compute the relative distance between pairs of colors. -/// This is used for color matching to a limited palette, such as in Sixel rendering. -/// -public interface IColorDistance -{ - /// - /// Computes a similarity metric between two instances. - /// A larger value indicates more dissimilar colors, while a smaller value indicates more similar colors. - /// The metric is internally consistent for the given algorithm. - /// - /// The first color. - /// The second color. - /// A numeric value representing the distance between the two colors. - double CalculateDistance (Color c1, Color c2); -} - -/// -/// 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. -/// -public class EuclideanColorDistance : IColorDistance -{ - public double CalculateDistance (Color c1, Color c2) - { - int rDiff = c1.R - c2.R; - int gDiff = c1.G - c2.G; - int bDiff = c1.B - c2.B; - return Math.Sqrt (rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); - } -} -public abstract class LabColorDistance : IColorDistance -{ - // Reference white point for D65 illuminant (can be moved to constants) - private const double RefX = 95.047; - private const double RefY = 100.000; - private const double RefZ = 108.883; - - // Conversion from RGB to Lab - protected LabColor RgbToLab (Color c) - { - var xyz = ColorHelper.ColorConverter.RgbToXyz (new RGB (c.R, c.G, c.B)); - - // Normalize XYZ values by reference white point - double x = xyz.X / RefX; - double y = xyz.Y / RefY; - double z = xyz.Z / RefZ; - - // Apply the nonlinear transformation for Lab - x = (x > 0.008856) ? Math.Pow (x, 1.0 / 3.0) : (7.787 * x) + (16.0 / 116.0); - y = (y > 0.008856) ? Math.Pow (y, 1.0 / 3.0) : (7.787 * y) + (16.0 / 116.0); - z = (z > 0.008856) ? Math.Pow (z, 1.0 / 3.0) : (7.787 * z) + (16.0 / 116.0); - - // Calculate Lab values - double l = (116.0 * y) - 16.0; - double a = 500.0 * (x - y); - double b = 200.0 * (y - z); - - return new LabColor (l, a, b); - } - - // LabColor class encapsulating L, A, and B values - protected class LabColor - { - public double L { get; } - public double A { get; } - public double B { get; } - - public LabColor (double l, double a, double b) - { - L = l; - A = a; - B = b; - } - } - - /// - public abstract double CalculateDistance (Color c1, Color c2); -} - -/// -/// This is the simplest method to measure color difference in the CIE Lab color space. The Euclidean distance in Lab -/// space is more aligned with human perception than RGB space, as Lab attempts to model how humans perceive color differences. -/// -public class CIE76ColorDistance : LabColorDistance -{ - public override double CalculateDistance (Color c1, Color c2) - { - var lab1 = RgbToLab (c1); - var lab2 = RgbToLab (c2); - - // Euclidean distance in Lab color space - return Math.Sqrt (Math.Pow (lab1.L - lab2.L, 2) + Math.Pow (lab1.A - lab2.A, 2) + Math.Pow (lab1.B - lab2.B, 2)); - } -} - -/// -/// CIE94 improves on CIE76 by introducing adjustments for chroma (color intensity) and lightness. -/// This algorithm considers human visual perception more accurately by scaling differences in lightness and chroma. -/// It is better but slower than . -/// -public class CIE94ColorDistance : LabColorDistance -{ - // Constants for CIE94 formula (can be modified for different use cases like textiles or graphics) - private const double kL = 1.0; - private const double kC = 1.0; - private const double kH = 1.0; - - public override double CalculateDistance (Color first, Color second) - { - var lab1 = RgbToLab (first); - var lab2 = RgbToLab (second); - - // Delta L, A, B - double deltaL = lab1.L - lab2.L; - double deltaA = lab1.A - lab2.A; - double deltaB = lab1.B - lab2.B; - - // Chroma values for both colors - double c1 = Math.Sqrt (lab1.A * lab1.A + lab1.B * lab1.B); - double c2 = Math.Sqrt (lab2.A * lab2.A + lab2.B * lab2.B); - double deltaC = c1 - c2; - - // Delta H (calculated indirectly) - double deltaH = Math.Sqrt (Math.Pow (deltaA, 2) + Math.Pow (deltaB, 2) - Math.Pow (deltaC, 2)); - - // Scaling factors - double sL = 1.0; - double sC = 1.0 + 0.045 * c1; - double sH = 1.0 + 0.015 * c1; - - // CIE94 color difference formula - return Math.Sqrt ( - Math.Pow (deltaL / (kL * sL), 2) + - Math.Pow (deltaC / (kC * sC), 2) + - Math.Pow (deltaH / (kH * sH), 2) - ); - } -} - - -class MedianCutPaletteBuilder : IPaletteBuilder -{ - public List BuildPalette (List colors, int maxColors) - { - // Initial step: place all colors in one large box - List boxes = new List { new ColorBox (colors) }; - - // Keep splitting boxes until we have the desired number of colors - while (boxes.Count < maxColors) - { - // Find the box with the largest range and split it - ColorBox boxToSplit = FindBoxWithLargestRange (boxes); - - if (boxToSplit == null || boxToSplit.Colors.Count == 0) - { - break; - } - - // Split the box into two smaller boxes - var splitBoxes = SplitBox (boxToSplit); - boxes.Remove (boxToSplit); - boxes.AddRange (splitBoxes); - } - - // Average the colors in each box to get the final palette - return boxes.Select (box => box.GetAverageColor ()).ToList (); - } - - // Find the box with the largest color range (R, G, or B) - private ColorBox FindBoxWithLargestRange (List boxes) - { - ColorBox largestRangeBox = null; - int largestRange = 0; - - foreach (var box in boxes) - { - int range = box.GetColorRange (); - if (range > largestRange) - { - largestRange = range; - largestRangeBox = box; - } - } - - return largestRangeBox; - } - - // Split a box at the median point in its largest color channel - private List SplitBox (ColorBox box) - { - List result = new List (); - - // Find the color channel with the largest range (R, G, or B) - int channel = box.GetLargestChannel (); - var sortedColors = box.Colors.OrderBy (c => GetColorChannelValue (c, channel)).ToList (); - - // Split the box at the median - int medianIndex = sortedColors.Count / 2; - - var lowerHalf = sortedColors.Take (medianIndex).ToList (); - var upperHalf = sortedColors.Skip (medianIndex).ToList (); - - result.Add (new ColorBox (lowerHalf)); - result.Add (new ColorBox (upperHalf)); - - return result; - } - - // Helper method to get the value of a color channel (R = 0, G = 1, B = 2) - private static int GetColorChannelValue (Color color, int channel) - { - switch (channel) - { - case 0: return color.R; - case 1: return color.G; - case 2: return color.B; - default: throw new ArgumentException ("Invalid channel index"); - } - } - - // The ColorBox class to represent a subset of colors - public class ColorBox - { - public List Colors { get; private set; } - - public ColorBox (List colors) - { - Colors = colors; - } - - // Get the color channel with the largest range (0 = R, 1 = G, 2 = B) - public int GetLargestChannel () - { - int rRange = GetColorRangeForChannel (0); - int gRange = GetColorRangeForChannel (1); - int bRange = GetColorRangeForChannel (2); - - if (rRange >= gRange && rRange >= bRange) - { - return 0; - } - - if (gRange >= rRange && gRange >= bRange) - { - return 1; - } - - return 2; - } - - // Get the range of colors for a given channel (0 = R, 1 = G, 2 = B) - private int GetColorRangeForChannel (int channel) - { - int min = int.MaxValue, max = int.MinValue; - - foreach (var color in Colors) - { - int value = GetColorChannelValue (color, channel); - if (value < min) - { - min = value; - } - - if (value > max) - { - max = value; - } - } - - return max - min; - } - - // Get the overall color range across all channels (for finding the box to split) - public int GetColorRange () - { - int rRange = GetColorRangeForChannel (0); - int gRange = GetColorRangeForChannel (1); - int bRange = GetColorRangeForChannel (2); - - return Math.Max (rRange, Math.Max (gRange, bRange)); - } - - // Calculate the average color in the box - public Color GetAverageColor () - { - int totalR = 0, totalG = 0, totalB = 0; - - foreach (var color in Colors) - { - totalR += color.R; - totalG += color.G; - totalB += color.B; - } - - int count = Colors.Count; - return new Color (totalR / count, totalG / count, totalB / count); - } - } -} \ No newline at end of file diff --git a/Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs b/Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs new file mode 100644 index 0000000000..6722f55f11 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs @@ -0,0 +1,17 @@ +namespace Terminal.Gui.Drawing.Quant; + +/// +/// This is the simplest method to measure color difference in the CIE Lab color space. The Euclidean distance in Lab +/// space is more aligned with human perception than RGB space, as Lab attempts to model how humans perceive color differences. +/// +public class CIE76ColorDistance : LabColorDistance +{ + public override double CalculateDistance (Color c1, Color c2) + { + var lab1 = RgbToLab (c1); + var lab2 = RgbToLab (c2); + + // Euclidean distance in Lab color space + return Math.Sqrt (Math.Pow (lab1.L - lab2.L, 2) + Math.Pow (lab1.A - lab2.A, 2) + Math.Pow (lab1.B - lab2.B, 2)); + } +} diff --git a/Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs b/Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs new file mode 100644 index 0000000000..cbc0e22e58 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs @@ -0,0 +1,45 @@ +namespace Terminal.Gui.Drawing.Quant; + +/// +/// CIE94 improves on CIE76 by introducing adjustments for chroma (color intensity) and lightness. +/// This algorithm considers human visual perception more accurately by scaling differences in lightness and chroma. +/// It is better but slower than . +/// +public class CIE94ColorDistance : LabColorDistance +{ + // Constants for CIE94 formula (can be modified for different use cases like textiles or graphics) + private const double kL = 1.0; + private const double kC = 1.0; + private const double kH = 1.0; + + public override double CalculateDistance (Color first, Color second) + { + var lab1 = RgbToLab (first); + var lab2 = RgbToLab (second); + + // Delta L, A, B + double deltaL = lab1.L - lab2.L; + double deltaA = lab1.A - lab2.A; + double deltaB = lab1.B - lab2.B; + + // Chroma values for both colors + double c1 = Math.Sqrt (lab1.A * lab1.A + lab1.B * lab1.B); + double c2 = Math.Sqrt (lab2.A * lab2.A + lab2.B * lab2.B); + double deltaC = c1 - c2; + + // Delta H (calculated indirectly) + double deltaH = Math.Sqrt (Math.Pow (deltaA, 2) + Math.Pow (deltaB, 2) - Math.Pow (deltaC, 2)); + + // Scaling factors + double sL = 1.0; + double sC = 1.0 + 0.045 * c1; + double sH = 1.0 + 0.015 * c1; + + // CIE94 color difference formula + return Math.Sqrt ( + Math.Pow (deltaL / (kL * sL), 2) + + Math.Pow (deltaC / (kC * sC), 2) + + Math.Pow (deltaH / (kH * sH), 2) + ); + } +} diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs new file mode 100644 index 0000000000..4c8670f315 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -0,0 +1,71 @@ +using System.Collections.ObjectModel; + +namespace Terminal.Gui.Drawing.Quant; + +/// +/// Translates colors in an image into a Palette of up to 256 colors. +/// +public class ColorQuantizer +{ + /// + /// Gets the current colors in the palette based on the last call to + /// . + /// + public IReadOnlyCollection Palette { get; private set; } = new List (); + + /// + /// Gets or sets the maximum number of colors to put into the . + /// Defaults to 256 (the maximum for sixel images). + /// + public int MaxColors { get; set; } = 256; + + /// + /// Gets or sets the algorithm used to map novel colors into existing + /// palette colors (closest match). Defaults to + /// + public IColorDistance DistanceAlgorithm { get; set; } = new CIE94ColorDistance (); + + /// + /// Gets or sets the algorithm used to build the . + /// Defaults to + /// + public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new MedianCutPaletteBuilder (); + + public void BuildPalette (Color [,] pixels) + { + List allColors = new List (); + int width = pixels.GetLength (0); + int height = pixels.GetLength (1); + + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + allColors.Add (pixels [x, y]); + } + } + + Palette = PaletteBuildingAlgorithm.BuildPalette (allColors, MaxColors); + } + + public int GetNearestColor (Color toTranslate) + { + // Simple nearest color matching based on Euclidean distance in RGB space + double minDistance = double.MaxValue; + int nearestIndex = 0; + + for (var index = 0; index < Palette.Count; index++) + { + Color color = Palette.ElementAt (index); + double distance = DistanceAlgorithm.CalculateDistance (color, toTranslate); + + if (distance < minDistance) + { + minDistance = distance; + nearestIndex = index; + } + } + + return nearestIndex; + } +} \ No newline at end of file diff --git a/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs b/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs new file mode 100644 index 0000000000..b120006238 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs @@ -0,0 +1,16 @@ +namespace Terminal.Gui.Drawing.Quant; + +/// +/// 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. +/// +public class EuclideanColorDistance : IColorDistance +{ + public double CalculateDistance (Color c1, Color c2) + { + int rDiff = c1.R - c2.R; + int gDiff = c1.G - c2.G; + int bDiff = c1.B - c2.B; + return Math.Sqrt (rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); + } +} diff --git a/Terminal.Gui/Drawing/Quant/IColorDistance.cs b/Terminal.Gui/Drawing/Quant/IColorDistance.cs new file mode 100644 index 0000000000..85b5176485 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/IColorDistance.cs @@ -0,0 +1,18 @@ +namespace Terminal.Gui.Drawing.Quant; + +/// +/// Interface for algorithms that compute the relative distance between pairs of colors. +/// This is used for color matching to a limited palette, such as in Sixel rendering. +/// +public interface IColorDistance +{ + /// + /// Computes a similarity metric between two instances. + /// A larger value indicates more dissimilar colors, while a smaller value indicates more similar colors. + /// The metric is internally consistent for the given algorithm. + /// + /// The first color. + /// The second color. + /// A numeric value representing the distance between the two colors. + double CalculateDistance (Color c1, Color c2); +} diff --git a/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs new file mode 100644 index 0000000000..e66d00ebb0 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs @@ -0,0 +1,6 @@ +namespace Terminal.Gui.Drawing.Quant; + +public interface IPaletteBuilder +{ + List BuildPalette (List colors, int maxColors); +} diff --git a/Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs new file mode 100644 index 0000000000..0cf8bb0eb7 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Terminal.Gui.Drawing.Quant; + + /// + /// that works well for images with high contrast images + /// + 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 BuildPalette (List colors, int maxColors) + { + // Convert colors to vectors + List colorVectors = colors.Select (c => new ColorVector (c.R, c.G, c.B)).ToList (); + + // Perform K-Means Clustering + List 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 KMeans (List colors, int k) + { + // Randomly initialize k centroids + List centroids = InitializeCentroids (colors, k); + + List previousCentroids = new List (); + 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 InitializeCentroids (List colors, int k) + { + return colors.OrderBy (c => random.Next ()).Take (k).ToList (); // Randomly select k initial centroids + } + + private Dictionary> AssignColorsToClusters (List colors, List centroids) + { + var clusters = centroids.ToDictionary (c => c, c => new List ()); + + 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 RecomputeCentroids (Dictionary> clusters) + { + var newCentroids = new List (); + + 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 currentCentroids, List 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); + } + } +} diff --git a/Terminal.Gui/Drawing/Quant/LabColorDistance.cs b/Terminal.Gui/Drawing/Quant/LabColorDistance.cs new file mode 100644 index 0000000000..0670158ef1 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/LabColorDistance.cs @@ -0,0 +1,52 @@ +using ColorHelper; + +namespace Terminal.Gui.Drawing.Quant; + +public abstract class LabColorDistance : IColorDistance +{ + // Reference white point for D65 illuminant (can be moved to constants) + private const double RefX = 95.047; + private const double RefY = 100.000; + private const double RefZ = 108.883; + + // Conversion from RGB to Lab + protected LabColor RgbToLab (Color c) + { + var xyz = ColorHelper.ColorConverter.RgbToXyz (new RGB (c.R, c.G, c.B)); + + // Normalize XYZ values by reference white point + double x = xyz.X / RefX; + double y = xyz.Y / RefY; + double z = xyz.Z / RefZ; + + // Apply the nonlinear transformation for Lab + x = x > 0.008856 ? Math.Pow (x, 1.0 / 3.0) : 7.787 * x + 16.0 / 116.0; + y = y > 0.008856 ? Math.Pow (y, 1.0 / 3.0) : 7.787 * y + 16.0 / 116.0; + z = z > 0.008856 ? Math.Pow (z, 1.0 / 3.0) : 7.787 * z + 16.0 / 116.0; + + // Calculate Lab values + double l = 116.0 * y - 16.0; + double a = 500.0 * (x - y); + double b = 200.0 * (y - z); + + return new LabColor (l, a, b); + } + + // LabColor class encapsulating L, A, and B values + protected class LabColor + { + public double L { get; } + public double A { get; } + public double B { get; } + + public LabColor (double l, double a, double b) + { + L = l; + A = a; + B = b; + } + } + + /// + public abstract double CalculateDistance (Color c1, Color c2); +} diff --git a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs new file mode 100644 index 0000000000..7e7abb9358 --- /dev/null +++ b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs @@ -0,0 +1,161 @@ +namespace Terminal.Gui.Drawing.Quant; + +public class MedianCutPaletteBuilder : IPaletteBuilder +{ + public List BuildPalette (List colors, int maxColors) + { + // Initial step: place all colors in one large box + List boxes = new List { new ColorBox (colors) }; + + // Keep splitting boxes until we have the desired number of colors + while (boxes.Count < maxColors) + { + // Find the box with the largest range and split it + ColorBox boxToSplit = FindBoxWithLargestRange (boxes); + + if (boxToSplit == null || boxToSplit.Colors.Count == 0) + { + break; + } + + // Split the box into two smaller boxes + var splitBoxes = SplitBox (boxToSplit); + boxes.Remove (boxToSplit); + boxes.AddRange (splitBoxes); + } + + // Average the colors in each box to get the final palette + return boxes.Select (box => box.GetAverageColor ()).ToList (); + } + + // Find the box with the largest color range (R, G, or B) + private ColorBox FindBoxWithLargestRange (List boxes) + { + ColorBox largestRangeBox = null; + int largestRange = 0; + + foreach (var box in boxes) + { + int range = box.GetColorRange (); + if (range > largestRange) + { + largestRange = range; + largestRangeBox = box; + } + } + + return largestRangeBox; + } + + // Split a box at the median point in its largest color channel + private List SplitBox (ColorBox box) + { + List result = new List (); + + // Find the color channel with the largest range (R, G, or B) + int channel = box.GetLargestChannel (); + var sortedColors = box.Colors.OrderBy (c => GetColorChannelValue (c, channel)).ToList (); + + // Split the box at the median + int medianIndex = sortedColors.Count / 2; + + var lowerHalf = sortedColors.Take (medianIndex).ToList (); + var upperHalf = sortedColors.Skip (medianIndex).ToList (); + + result.Add (new ColorBox (lowerHalf)); + result.Add (new ColorBox (upperHalf)); + + return result; + } + + // Helper method to get the value of a color channel (R = 0, G = 1, B = 2) + private static int GetColorChannelValue (Color color, int channel) + { + switch (channel) + { + case 0: return color.R; + case 1: return color.G; + case 2: return color.B; + default: throw new ArgumentException ("Invalid channel index"); + } + } + + // The ColorBox class to represent a subset of colors + public class ColorBox + { + public List Colors { get; private set; } + + public ColorBox (List colors) + { + Colors = colors; + } + + // Get the color channel with the largest range (0 = R, 1 = G, 2 = B) + public int GetLargestChannel () + { + int rRange = GetColorRangeForChannel (0); + int gRange = GetColorRangeForChannel (1); + int bRange = GetColorRangeForChannel (2); + + if (rRange >= gRange && rRange >= bRange) + { + return 0; + } + + if (gRange >= rRange && gRange >= bRange) + { + return 1; + } + + return 2; + } + + // Get the range of colors for a given channel (0 = R, 1 = G, 2 = B) + private int GetColorRangeForChannel (int channel) + { + int min = int.MaxValue, max = int.MinValue; + + foreach (var color in Colors) + { + int value = GetColorChannelValue (color, channel); + if (value < min) + { + min = value; + } + + if (value > max) + { + max = value; + } + } + + return max - min; + } + + // Get the overall color range across all channels (for finding the box to split) + public int GetColorRange () + { + int rRange = GetColorRangeForChannel (0); + int gRange = GetColorRangeForChannel (1); + int bRange = GetColorRangeForChannel (2); + + return Math.Max (rRange, Math.Max (gRange, bRange)); + } + + // Calculate the average color in the box + public Color GetAverageColor () + { + int totalR = 0, totalG = 0, totalB = 0; + + foreach (var color in Colors) + { + totalR += color.R; + totalG += color.G; + totalB += color.B; + } + + int count = Colors.Count; + return new Color (totalR / count, totalG / count, totalB / count); + } + } +} diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index eb5d7c1c53..383b1d9f0a 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -1,10 +1,19 @@ -namespace Terminal.Gui; +using Terminal.Gui.Drawing.Quant; + +namespace Terminal.Gui; /// /// Encodes a images into the sixel console image output format. /// public class SixelEncoder { + /// + /// Gets or sets the quantizer responsible for building a representative + /// limited color palette for images and for mapping novel colors in + /// images to their closest palette color + /// + public ColorQuantizer Quantizer { get; set; } = new (); + /// /// Encode the given bitmap into sixel encoding /// @@ -21,9 +30,9 @@ public string EncodeSixel (Color [,] pixels) string fillArea = GetFillArea (pixels); - string pallette = GetColorPallette (pixels, out var quantizer); + string pallette = GetColorPallette (pixels ); - string pixelData = WriteSixel (pixels, quantizer); + string pixelData = WriteSixel (pixels); const string terminator = "\u001b\\"; // End sixel sequence @@ -43,7 +52,7 @@ [ ] - Bit 4 [ ] - Bit 5 (bottom-most pixel) */ - private string WriteSixel (Color [,] pixels, ColorQuantizer quantizer) + private string WriteSixel (Color [,] pixels) { StringBuilder sb = new StringBuilder (); int height = pixels.GetLength (1); @@ -55,7 +64,7 @@ private string WriteSixel (Color [,] pixels, ColorQuantizer quantizer) { int p = y * width; Color cachedColor = pixels [0, y]; - int cachedColorIndex = quantizer.GetNearestColor (cachedColor ); + int cachedColorIndex = Quantizer.GetNearestColor (cachedColor ); int count = 1; int c = -1; @@ -63,7 +72,7 @@ private string WriteSixel (Color [,] pixels, ColorQuantizer quantizer) for (int x = 0; x < width; x++) { Color color = pixels [x, y]; - int colorIndex = quantizer.GetNearestColor (color); + int colorIndex = Quantizer.GetNearestColor (color); if (colorIndex == cachedColorIndex) { @@ -159,19 +168,18 @@ This is used when you are working with multiple color layers or want to continue - private string GetColorPallette (Color [,] pixels, out ColorQuantizer quantizer) + private string GetColorPallette (Color [,] pixels) { - quantizer = new ColorQuantizer (); - quantizer.BuildPalette (pixels); + Quantizer.BuildPalette (pixels); // Color definitions in the format "#;;;;" - For type the 2 means RGB. The values range 0 to 100 StringBuilder paletteSb = new StringBuilder (); - for (int i = 0; i < quantizer.Palette.Count; i++) + for (int i = 0; i < Quantizer.Palette.Count; i++) { - var color = quantizer.Palette.ElementAt (i); + var color = Quantizer.Palette.ElementAt (i); paletteSb.AppendFormat ("#{0};2;{1};{2};{3}", i, color.R * 100 / 255, diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 59590708c9..5a1574cacf 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -6,6 +6,7 @@ using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using Terminal.Gui; +using Terminal.Gui.Drawing.Quant; using Color = Terminal.Gui.Color; namespace UICatalog.Scenarios; @@ -103,6 +104,8 @@ public override void Main () Application.Refresh (); }; + + var btnSixel = new Button () { X = Pos.Right (btnOpenImage) + 2, Y = 0, Text = "Output Sixel" }; btnSixel.Accept += (s, e) => { imageView.OutputSixel ();}; win.Add (btnSixel); @@ -169,6 +172,7 @@ public void OutputSixel () } var encoder = new SixelEncoder (); + encoder.Quantizer.PaletteBuildingAlgorithm = new KMeansPaletteBuilder (new EuclideanColorDistance()); var encoded = encoder.EncodeSixel (ConvertToColorArray (_fullResImage)); From 891adec260080d650d12cdfd46879f94547238b8 Mon Sep 17 00:00:00 2001 From: tznind Date: Fri, 13 Sep 2024 20:50:46 +0100 Subject: [PATCH 08/62] Switch to a new WriteSixel algorithm --- Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 2 +- Terminal.Gui/Drawing/SixelEncoder.cs | 183 +++++++++---------- UICatalog/Scenarios/Images.cs | 1 - 3 files changed, 87 insertions(+), 99 deletions(-) diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index 4c8670f315..3e3021e2b4 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -50,7 +50,7 @@ public void BuildPalette (Color [,] pixels) public int GetNearestColor (Color toTranslate) { - // Simple nearest color matching based on Euclidean distance in RGB space + // Simple nearest color matching based on DistanceAlgorithm double minDistance = double.MaxValue; int nearestIndex = 0; diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index 383b1d9f0a..ce1a6f9ae5 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -30,7 +30,7 @@ public string EncodeSixel (Color [,] pixels) string fillArea = GetFillArea (pixels); - string pallette = GetColorPallette (pixels ); + string pallette = GetColorPalette (pixels ); string pixelData = WriteSixel (pixels); @@ -52,6 +52,7 @@ [ ] - Bit 4 [ ] - Bit 5 (bottom-most pixel) */ + private string WriteSixel (Color [,] pixels) { StringBuilder sb = new StringBuilder (); @@ -60,131 +61,121 @@ private string WriteSixel (Color [,] pixels) int n = 1; // Used for checking when to add the line terminator // Iterate over each row of the image - for (int y = 0; y < height; y++) + for (int y = 0; y < height; y += 6) + { + sb.Append (ProcessBand (pixels, y, Math.Min (6, height - y), width)); + + // Line separator between bands + if (y + 6 < height) // Only add separator if not the last band + { + sb.Append ("-"); + } + } + + return sb.ToString (); + } + + private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int width) + { + var last = new sbyte [Quantizer.Palette.Count + 1]; + var code = new byte [Quantizer.Palette.Count + 1]; + var accu = new ushort [Quantizer.Palette.Count + 1]; + var slots = new short [Quantizer.Palette.Count + 1]; + + Array.Fill (last, (sbyte)-1); + Array.Fill (accu, (ushort)1); + Array.Fill (slots, (short)-1); + + var usedColorIdx = new List (); + var targets = new List> (); + + // Process columns within the band + for (int x = 0; x < width; ++x) { - int p = y * width; - Color cachedColor = pixels [0, y]; - int cachedColorIndex = Quantizer.GetNearestColor (cachedColor ); - int count = 1; - int c = -1; - - // Iterate through each column in the row - for (int x = 0; x < width; x++) + Array.Clear (code, 0, usedColorIdx.Count); + + // Process each row in the 6-pixel high band + for (int row = 0; row < bandHeight; ++row) { - Color color = pixels [x, y]; + var color = pixels [x, startY + row]; int colorIndex = Quantizer.GetNearestColor (color); - if (colorIndex == cachedColorIndex) + if (slots [colorIndex] == -1) { - count++; - } - else - { - // Output the cached color first - if (cachedColorIndex == -1) - { - c = 0x3f; // Key color or transparent - } - else + targets.Add (new List ()); + if (x > 0) { - c = 0x3f + n; - sb.AppendFormat ("#{0}", cachedColorIndex); + last [usedColorIdx.Count] = 0; + accu [usedColorIdx.Count] = (ushort)x; } - - // If count is less than 3, we simply repeat the character - if (count < 3) - { - sb.Append ((char)c, count); - } - else - { - // RLE if count is greater than 3 - sb.AppendFormat ("!{0}{1}", count, (char)c); - } - - // Reset for the new color - count = 1; - cachedColorIndex = colorIndex; + slots [colorIndex] = (short)usedColorIdx.Count; + usedColorIdx.Add (colorIndex); } + + code [slots [colorIndex]] |= (byte)(1 << row); // Accumulate SIXEL data } - // Handle the last run of the color - if (c != -1 && count > 1) + // Handle transitions between columns + for (int j = 0; j < usedColorIdx.Count; ++j) { - if (cachedColorIndex == -1) - { - c = 0x3f; // Key color - } - else - { - sb.AppendFormat ("#{0}", cachedColorIndex); - } - - if (count < 3) + if (code [j] == last [j]) { - sb.Append ((char)c, count); + accu [j]++; } else { - sb.AppendFormat ("!{0}{1}", count, (char)c); + if (last [j] != -1) + { + targets [j].Add (CodeToSixel (last [j], accu [j])); + } + last [j] = (sbyte)code [j]; + accu [j] = 1; } } + } - // Line terminator or separator depending on `n` - if (n == 32) - { - /* - 2. Line Separator (-): - - The line separator instructs the sixel renderer to move to the next row of sixels. - After a -, the renderer will start a new row from the leftmost column. This marks the end of one line of sixel data and starts a new line. - This ensures that the sixel data drawn after the separator appears below the previous row rather than overprinting it. - - Use case: When you want to start drawing a new line of sixels (e.g., after completing a row of sixel columns). - */ - - n = 1; - sb.Append ("-"); // Write sixel line separator - } - else + // Process remaining data for this band + for (int j = 0; j < usedColorIdx.Count; ++j) + { + if (last [j] != 0) { - /* - *1. Line Terminator ($): - - The line terminator instructs the sixel renderer to return to the start of the current row but allows subsequent sixel characters to be overprinted on the same row. - This is used when you are working with multiple color layers or want to continue drawing in the same row but with a different color. - The $ allows you to overwrite sixel characters in the same vertical position by using different colors, effectively allowing you to combine colors on a per-sixel basis. - - Use case: When you need to draw multiple colors within the same vertical slice of 6 pixels. - */ - - n <<= 1; - sb.Append ("$"); // Write line terminator + targets [j].Add (CodeToSixel (last [j], accu [j])); } } - return sb.ToString (); - } + // Build the final output for this band + var result = new StringBuilder (); + for (int j = 0; j < usedColorIdx.Count; ++j) + { + result.Append ($"#{usedColorIdx [j]}{string.Join ("", targets [j])}$"); + } + return result.ToString (); + } + private static string CodeToSixel (int code, int repeat) + { + char c = (char)(code + 63); + if (repeat > 3) return "!" + repeat + c; + if (repeat == 3) return c.ToString () + c + c; + if (repeat == 2) return c.ToString () + c; + return c.ToString (); + } - private string GetColorPallette (Color [,] pixels) + private string GetColorPalette (Color [,] pixels) { Quantizer.BuildPalette (pixels); - - // Color definitions in the format "#;;;;" - For type the 2 means RGB. The values range 0 to 100 - StringBuilder paletteSb = new StringBuilder (); for (int i = 0; i < Quantizer.Palette.Count; i++) { var color = Quantizer.Palette.ElementAt (i); paletteSb.AppendFormat ("#{0};2;{1};{2};{3}", - i, - color.R * 100 / 255, - color.G * 100 / 255, - color.B * 100 / 255); + i, + color.R * 100 / 255, + color.G * 100 / 255, + color.B * 100 / 255); } return paletteSb.ToString (); @@ -197,17 +188,15 @@ private string GetFillArea (Color [,] pixels) return $"{widthInChars};{heightInChars}"; } + private int GetHeightInChars (Color [,] pixels) { - // Height in pixels is equal to the number of rows in the pixel array int height = pixels.GetLength (1); - - // Each SIXEL character represents 6 pixels vertically - return (height + 5) / 6; // Equivalent to ceiling(height / 6) + return (height + 5) / 6; } + private int GetWidthInChars (Color [,] pixels) { - // Width in pixels is equal to the number of columns in the pixel array return pixels.GetLength (0); } } \ No newline at end of file diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 5a1574cacf..e5d460461f 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -172,7 +172,6 @@ public void OutputSixel () } var encoder = new SixelEncoder (); - encoder.Quantizer.PaletteBuildingAlgorithm = new KMeansPaletteBuilder (new EuclideanColorDistance()); var encoded = encoder.EncodeSixel (ConvertToColorArray (_fullResImage)); From 484b75ad102cf87e5744e8010d519cbffd2f47c3 Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 14 Sep 2024 16:17:54 +0100 Subject: [PATCH 09/62] Output palette in Images scenario --- UICatalog/Scenarios/Images.cs | 93 +++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index e5d460461f..4711ebbc4f 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; @@ -175,6 +177,25 @@ public void OutputSixel () var encoded = encoder.EncodeSixel (ConvertToColorArray (_fullResImage)); + var pv = new PaletteView (encoder.Quantizer.Palette.ToList ()); + + var dlg = new Dialog () + { + Title = "Palette (Esc to close)", + Width = Dim.Fill (2), + Height = Dim.Fill (1), + }; + + var btn = new Button () + { + Text = "Ok" + }; + + btn.Accept += (s, e) => Application.RequestStop (); + dlg.Add (pv); + dlg.AddButton (btn); + Application.Run (dlg); + Application.Sixel = encoded; } public static Color [,] ConvertToColorArray (Image image) @@ -196,4 +217,76 @@ public void OutputSixel () return colors; } } + public class PaletteView : View + { + private List _palette; + + public PaletteView (List palette) + { + _palette = palette ?? new List (); + Width = Dim.Fill (); + Height = Dim.Fill (); + } + + // Automatically calculates rows and columns based on the available bounds + private (int columns, int rows) CalculateGridSize (Rectangle bounds) + { + // Characters are twice as wide as they are tall, so use 2:1 width-to-height ratio + int availableWidth = bounds.Width / 2; // Each color block is 2 character wide + int availableHeight = bounds.Height; + + int numColors = _palette.Count; + + // Calculate the number of columns and rows we can fit within the bounds + int columns = Math.Min (availableWidth, numColors); + int rows = (numColors + columns - 1) / columns; // Ceiling division for rows + + // Ensure we do not exceed the available height + if (rows > availableHeight) + { + rows = availableHeight; + columns = (numColors + rows - 1) / rows; // Recalculate columns if needed + } + + return (columns, rows); + } + + public override void OnDrawContent (Rectangle bounds) + { + base.OnDrawContent (bounds); + + if (_palette == null || _palette.Count == 0) + return; + + // Calculate the grid size based on the bounds + var (columns, rows) = CalculateGridSize (bounds); + + // Draw the colors in the palette + for (int i = 0; i < _palette.Count && i < columns * rows; i++) + { + int row = i / columns; + int col = i % columns; + + // Calculate position in the grid + int x = col * 2; // Each color block takes up 2 horizontal spaces + int y = row; + + // Set the color attribute for the block + Driver.SetAttribute (new Terminal.Gui.Attribute (_palette [i], _palette [i])); + + // Draw the block (2 characters wide per block) + for (int dx = 0; dx < 2; dx++) // Fill the width of the block + { + AddRune (x + dx, y, (Rune)' '); + } + } + } + + // Allows dynamically changing the palette + public void SetPalette (List palette) + { + _palette = palette ?? new List (); + SetNeedsDisplay (); + } + } } From 68d5e995d1802d0f7adead6eef4861c61bfb2f02 Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 14 Sep 2024 16:42:25 +0100 Subject: [PATCH 10/62] Fix ConvertToColorArray and namespaces --- .../Drawing/Quant/CIE76ColorDistance.cs | 2 +- .../Drawing/Quant/CIE94ColorDistance.cs | 2 +- Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 6 +- .../Drawing/Quant/EuclideanColorDistance.cs | 2 +- Terminal.Gui/Drawing/Quant/IColorDistance.cs | 2 +- Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs | 2 +- .../Drawing/Quant/LabColorDistance.cs | 2 +- .../Drawing/Quant/MedianCutPaletteBuilder.cs | 129 +++++++----------- Terminal.Gui/Drawing/SixelEncoder.cs | 2 +- UICatalog/Scenarios/Images.cs | 3 +- 10 files changed, 64 insertions(+), 88 deletions(-) diff --git a/Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs b/Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs index 6722f55f11..c2cc4d12e8 100644 --- a/Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs +++ b/Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs @@ -1,4 +1,4 @@ -namespace Terminal.Gui.Drawing.Quant; +namespace Terminal.Gui; /// /// This is the simplest method to measure color difference in the CIE Lab color space. The Euclidean distance in Lab diff --git a/Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs b/Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs index cbc0e22e58..b5103638fa 100644 --- a/Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs +++ b/Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs @@ -1,4 +1,4 @@ -namespace Terminal.Gui.Drawing.Quant; +namespace Terminal.Gui; /// /// CIE94 improves on CIE76 by introducing adjustments for chroma (color intensity) and lightness. diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index 3e3021e2b4..1529a3eea0 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; +using Terminal.Gui.Drawing.Quant; -namespace Terminal.Gui.Drawing.Quant; +namespace Terminal.Gui; /// /// Translates colors in an image into a Palette of up to 256 colors. @@ -27,9 +28,8 @@ public class ColorQuantizer /// /// Gets or sets the algorithm used to build the . - /// Defaults to /// - public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new MedianCutPaletteBuilder (); + public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new KMeansPaletteBuilder (new EuclideanColorDistance ()) ; public void BuildPalette (Color [,] pixels) { diff --git a/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs b/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs index b120006238..49d754ed40 100644 --- a/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs +++ b/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs @@ -1,4 +1,4 @@ -namespace Terminal.Gui.Drawing.Quant; +namespace Terminal.Gui; /// /// Calculates the distance between two colors using Euclidean distance in 3D RGB space. diff --git a/Terminal.Gui/Drawing/Quant/IColorDistance.cs b/Terminal.Gui/Drawing/Quant/IColorDistance.cs index 85b5176485..8926943445 100644 --- a/Terminal.Gui/Drawing/Quant/IColorDistance.cs +++ b/Terminal.Gui/Drawing/Quant/IColorDistance.cs @@ -1,4 +1,4 @@ -namespace Terminal.Gui.Drawing.Quant; +namespace Terminal.Gui; /// /// Interface for algorithms that compute the relative distance between pairs of colors. diff --git a/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs index e66d00ebb0..e72de37696 100644 --- a/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs +++ b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs @@ -1,4 +1,4 @@ -namespace Terminal.Gui.Drawing.Quant; +namespace Terminal.Gui; public interface IPaletteBuilder { diff --git a/Terminal.Gui/Drawing/Quant/LabColorDistance.cs b/Terminal.Gui/Drawing/Quant/LabColorDistance.cs index 0670158ef1..f1d97b5907 100644 --- a/Terminal.Gui/Drawing/Quant/LabColorDistance.cs +++ b/Terminal.Gui/Drawing/Quant/LabColorDistance.cs @@ -1,6 +1,6 @@ using ColorHelper; -namespace Terminal.Gui.Drawing.Quant; +namespace Terminal.Gui; public abstract class LabColorDistance : IColorDistance { diff --git a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs index 7e7abb9358..9a49c9a64b 100644 --- a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs +++ b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs @@ -1,4 +1,4 @@ -namespace Terminal.Gui.Drawing.Quant; +namespace Terminal.Gui; public class MedianCutPaletteBuilder : IPaletteBuilder { @@ -10,7 +10,7 @@ public List BuildPalette (List colors, int maxColors) // Keep splitting boxes until we have the desired number of colors while (boxes.Count < maxColors) { - // Find the box with the largest range and split it + // Find the box with the largest brightness range and split it ColorBox boxToSplit = FindBoxWithLargestRange (boxes); if (boxToSplit == null || boxToSplit.Colors.Count == 0) @@ -18,25 +18,25 @@ public List BuildPalette (List colors, int maxColors) break; } - // Split the box into two smaller boxes - var splitBoxes = SplitBox (boxToSplit); + // 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.GetAverageColor ()).ToList (); + return boxes.Select (box => box.GetWeightedAverageColor ()).ToList (); } - // Find the box with the largest color range (R, G, or B) + // Find the box with the largest brightness range (based on luminance) private ColorBox FindBoxWithLargestRange (List boxes) { ColorBox largestRangeBox = null; - int largestRange = 0; + double largestRange = 0; foreach (var box in boxes) { - int range = box.GetColorRange (); + double range = box.GetBrightnessRange (); if (range > largestRange) { largestRange = range; @@ -47,14 +47,10 @@ private ColorBox FindBoxWithLargestRange (List boxes) return largestRangeBox; } - // Split a box at the median point in its largest color channel - private List SplitBox (ColorBox box) + // Split a box at the median point based on brightness (luminance) + private List SplitBoxByLuminance (ColorBox box) { - List result = new List (); - - // Find the color channel with the largest range (R, G, or B) - int channel = box.GetLargestChannel (); - var sortedColors = box.Colors.OrderBy (c => GetColorChannelValue (c, channel)).ToList (); + var sortedColors = box.Colors.OrderBy (c => GetBrightness (c)).ToList (); // Split the box at the median int medianIndex = sortedColors.Count / 2; @@ -62,22 +58,18 @@ private List SplitBox (ColorBox box) var lowerHalf = sortedColors.Take (medianIndex).ToList (); var upperHalf = sortedColors.Skip (medianIndex).ToList (); - result.Add (new ColorBox (lowerHalf)); - result.Add (new ColorBox (upperHalf)); - - return result; + return new List + { + new ColorBox(lowerHalf), + new ColorBox(upperHalf) + }; } - // Helper method to get the value of a color channel (R = 0, G = 1, B = 2) - private static int GetColorChannelValue (Color color, int channel) + // Calculate the brightness (luminance) of a color + private static double GetBrightness (Color color) { - switch (channel) - { - case 0: return color.R; - case 1: return color.G; - case 2: return color.B; - default: throw new ArgumentException ("Invalid channel index"); - } + // Luminance formula (standard) + return 0.299 * color.R + 0.587 * color.G + 0.114 * color.B; } // The ColorBox class to represent a subset of colors @@ -90,72 +82,57 @@ public ColorBox (List colors) Colors = colors; } - // Get the color channel with the largest range (0 = R, 1 = G, 2 = B) - public int GetLargestChannel () - { - int rRange = GetColorRangeForChannel (0); - int gRange = GetColorRangeForChannel (1); - int bRange = GetColorRangeForChannel (2); - - if (rRange >= gRange && rRange >= bRange) - { - return 0; - } - - if (gRange >= rRange && gRange >= bRange) - { - return 1; - } - - return 2; - } - - // Get the range of colors for a given channel (0 = R, 1 = G, 2 = B) - private int GetColorRangeForChannel (int channel) + // Get the range of brightness (luminance) in this box + public double GetBrightnessRange () { - int min = int.MaxValue, max = int.MinValue; + double minBrightness = double.MaxValue, maxBrightness = double.MinValue; foreach (var color in Colors) { - int value = GetColorChannelValue (color, channel); - if (value < min) + double brightness = GetBrightness (color); + if (brightness < minBrightness) { - min = value; + minBrightness = brightness; } - if (value > max) + if (brightness > maxBrightness) { - max = value; + maxBrightness = brightness; } } - return max - min; - } - - // Get the overall color range across all channels (for finding the box to split) - public int GetColorRange () - { - int rRange = GetColorRangeForChannel (0); - int gRange = GetColorRangeForChannel (1); - int bRange = GetColorRangeForChannel (2); - - return Math.Max (rRange, Math.Max (gRange, bRange)); + return maxBrightness - minBrightness; } - // Calculate the average color in the box - public Color GetAverageColor () + // Calculate the average color in the box, weighted by brightness (darker colors have more weight) + public Color GetWeightedAverageColor () { - int totalR = 0, totalG = 0, totalB = 0; + double totalR = 0, totalG = 0, totalB = 0; + double totalWeight = 0; foreach (var color in Colors) { - totalR += color.R; - totalG += color.G; - totalB += color.B; + 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; } - int count = Colors.Count; - return new Color (totalR / count, totalG / count, totalB / count); + // 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; } } -} +} \ No newline at end of file diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index ce1a6f9ae5..662b9b31c3 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -1,4 +1,4 @@ -using Terminal.Gui.Drawing.Quant; +using Terminal.Gui; namespace Terminal.Gui; diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 4711ebbc4f..eb84649add 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -8,7 +8,6 @@ using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using Terminal.Gui; -using Terminal.Gui.Drawing.Quant; using Color = Terminal.Gui.Color; namespace UICatalog.Scenarios; @@ -210,7 +209,7 @@ public void OutputSixel () for (int y = 0; y < height; y++) { var pixel = image [x, y]; - colors [x, y] = new Color (pixel.A, pixel.R, pixel.G, pixel.B); // Convert Rgba32 to System.Drawing.Color + colors [x, y] = new Color (pixel.R, pixel.G, pixel.B); // Convert Rgba32 to System.Drawing.Color } } From d747867663552a19816d10cc0da0fb06009921ec Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 14 Sep 2024 16:44:11 +0100 Subject: [PATCH 11/62] Fix comment --- UICatalog/Scenarios/Images.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index eb84649add..8e50d89144 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -203,13 +203,13 @@ public void OutputSixel () int height = image.Height; Color [,] colors = new Color [width, height]; - // Loop through each pixel and convert Rgba32 to System.Drawing.Color + // Loop through each pixel and convert Rgba32 to Terminal.Gui color for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { var pixel = image [x, y]; - colors [x, y] = new Color (pixel.R, pixel.G, pixel.B); // Convert Rgba32 to System.Drawing.Color + colors [x, y] = new Color (pixel.R, pixel.G, pixel.B); // Convert Rgba32 to Terminal.Gui color } } From f103b04c261f0a658ffe125a338415ebbb51164d Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 15 Sep 2024 11:28:42 +0100 Subject: [PATCH 12/62] Attribution for the WriteSixel method --- Terminal.Gui/Drawing/SixelEncoder.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index 662b9b31c3..f88370c56a 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -41,6 +41,7 @@ public string EncodeSixel (Color [,] pixels) /* + A sixel is a column of 6 pixels - with a width of 1 pixel Column controlled by one sixel character: @@ -52,7 +53,13 @@ [ ] - Bit 4 [ ] - Bit 5 (bottom-most pixel) */ - + /** + * This method is adapted from + * https://github.com/jerch/node-sixel/ + * + * Copyright (c) 2019 Joerg Breitbart. + * @license MIT + */ private string WriteSixel (Color [,] pixels) { StringBuilder sb = new StringBuilder (); From cbef6c591ad862ba9fbdba2fdcef77ffaeb5e687 Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 15 Sep 2024 11:36:23 +0100 Subject: [PATCH 13/62] Add comments --- Terminal.Gui/Drawing/SixelEncoder.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index f88370c56a..dfe9f63a4c 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -51,6 +51,15 @@ [ ] - 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 key. It moves drawing + cursor back to beginning of the current line + e.g. to draw more color layers. + */ /** @@ -62,12 +71,14 @@ [ ] - Bit 4 */ private string WriteSixel (Color [,] pixels) { + StringBuilder sb = new StringBuilder (); int height = pixels.GetLength (1); int width = pixels.GetLength (0); - int n = 1; // Used for checking when to add the line terminator - // Iterate over each row of the image + // Iterate over each 'row' of the image. Because each sixel write operation + // outputs a screen area 6 pixels high (and 1+ across) we must process the image + // 6 'y' units at once (1 band) for (int y = 0; y < height; y += 6) { sb.Append (ProcessBand (pixels, y, Math.Min (6, height - y), width)); @@ -75,6 +86,9 @@ private string WriteSixel (Color [,] pixels) // Line separator between bands if (y + 6 < height) // Only add separator if not the last band { + // This completes the drawing of the current line of sixel and + // returns the 'cursor' to beginning next line, newly drawn sixel + // after this will draw in the next 6 pixel high band (i.e. below). sb.Append ("-"); } } From eaa5c0e5555bf7a4f68e2ca39b66077022013c57 Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 15 Sep 2024 15:36:43 +0100 Subject: [PATCH 14/62] Simplify and speed up palette building --- Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 5 +- Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs | 11 + .../Drawing/Quant/KMeansPaletteBuilder.cs | 154 -------------- .../Drawing/Quant/MedianCutPaletteBuilder.cs | 189 ++++++++---------- Terminal.Gui/Drawing/SixelEncoder.cs | 46 ++--- UnitTests/Drawing/SixelEncoderTests.cs | 4 + 6 files changed, 127 insertions(+), 282 deletions(-) delete mode 100644 Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index 1529a3eea0..4b32e1669e 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -1,5 +1,4 @@ -using System.Collections.ObjectModel; -using Terminal.Gui.Drawing.Quant; + namespace Terminal.Gui; @@ -29,7 +28,7 @@ public class ColorQuantizer /// /// Gets or sets the algorithm used to build the . /// - public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new KMeansPaletteBuilder (new EuclideanColorDistance ()) ; + public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new MedianCutPaletteBuilder (new EuclideanColorDistance ()) ; public void BuildPalette (Color [,] pixels) { diff --git a/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs index e72de37696..999297cff0 100644 --- a/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs +++ b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs @@ -1,6 +1,17 @@ namespace Terminal.Gui; +/// +/// Builds a palette of a given size for a given set of input colors. +/// public interface IPaletteBuilder { + /// + /// Reduce the number of to (or less) + /// using an appropriate selection algorithm. + /// + /// Color of every pixel in the image. Contains duplication in order + /// to support algorithms that weigh how common a color is. + /// The maximum number of colours that should be represented. + /// List BuildPalette (List colors, int maxColors); } diff --git a/Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs deleted file mode 100644 index 0cf8bb0eb7..0000000000 --- a/Terminal.Gui/Drawing/Quant/KMeansPaletteBuilder.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Terminal.Gui.Drawing.Quant; - - /// - /// that works well for images with high contrast images - /// - 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 BuildPalette (List colors, int maxColors) - { - // Convert colors to vectors - List colorVectors = colors.Select (c => new ColorVector (c.R, c.G, c.B)).ToList (); - - // Perform K-Means Clustering - List 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 KMeans (List colors, int k) - { - // Randomly initialize k centroids - List centroids = InitializeCentroids (colors, k); - - List previousCentroids = new List (); - 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 InitializeCentroids (List colors, int k) - { - return colors.OrderBy (c => random.Next ()).Take (k).ToList (); // Randomly select k initial centroids - } - - private Dictionary> AssignColorsToClusters (List colors, List centroids) - { - var clusters = centroids.ToDictionary (c => c, c => new List ()); - - 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 RecomputeCentroids (Dictionary> clusters) - { - var newCentroids = new List (); - - 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 currentCentroids, List 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); - } - } -} diff --git a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs index 9a49c9a64b..cdafdfaa33 100644 --- a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs +++ b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs @@ -1,138 +1,123 @@ namespace Terminal.Gui; - public class MedianCutPaletteBuilder : IPaletteBuilder { - public List BuildPalette (List colors, int maxColors) + private readonly IColorDistance _colorDistance; + + public MedianCutPaletteBuilder (IColorDistance colorDistance) { - // Initial step: place all colors in one large box - List boxes = new List { new ColorBox (colors) }; + _colorDistance = colorDistance; + } - // Keep splitting boxes until we have the desired number of colors - while (boxes.Count < maxColors) + public List BuildPalette (List colors, int maxColors) + { + if (colors == null || colors.Count == 0 || maxColors <= 0) { - // Find the box with the largest brightness range and split it - ColorBox boxToSplit = FindBoxWithLargestRange (boxes); - - if (boxToSplit == null || boxToSplit.Colors.Count == 0) - { - break; - } - - // Split the box into two smaller boxes, based on luminance - var splitBoxes = SplitBoxByLuminance (boxToSplit); - boxes.Remove (boxToSplit); - boxes.AddRange (splitBoxes); + return new List (); } - // Average the colors in each box to get the final palette - return boxes.Select (box => box.GetWeightedAverageColor ()).ToList (); + return MedianCut (colors, maxColors); } - // Find the box with the largest brightness range (based on luminance) - private ColorBox FindBoxWithLargestRange (List boxes) + private List MedianCut (List colors, int maxColors) { - ColorBox largestRangeBox = null; - double largestRange = 0; + var cubes = new List> () { colors }; - foreach (var box in boxes) + // Recursively split color regions + while (cubes.Count < maxColors) { - double range = box.GetBrightnessRange (); - if (range > largestRange) + 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 ()) { - largestRange = range; - largestRangeBox = box; + cubes.Add (cube2); + added = true; + } + + if (!added) + { + break; } } - return largestRangeBox; + // Calculate average color for each cube + return cubes.Select (AverageColor).Distinct().ToList (); } - // Split a box at the median point based on brightness (luminance) - private List SplitBoxByLuminance (ColorBox box) + // Splits the cube based on the largest color component range + private (List, List) SplitCube (List cube) { - var sortedColors = box.Colors.OrderBy (c => GetBrightness (c)).ToList (); + var (component, range) = FindLargestRange (cube); - // Split the box at the median - int medianIndex = sortedColors.Count / 2; + // 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 lowerHalf = sortedColors.Take (medianIndex).ToList (); - var upperHalf = sortedColors.Skip (medianIndex).ToList (); + var medianIndex = cube.Count / 2; + var cube1 = cube.Take (medianIndex).ToList (); + var cube2 = cube.Skip (medianIndex).ToList (); - return new List - { - new ColorBox(lowerHalf), - new ColorBox(upperHalf) - }; + return (cube1, cube2); } - // Calculate the brightness (luminance) of a color - private static double GetBrightness (Color color) + private (int, int) FindLargestRange (List cube) { - // Luminance formula (standard) - return 0.299 * color.R + 0.587 * color.G + 0.114 * color.B; + 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); } - // The ColorBox class to represent a subset of colors - public class ColorBox + private Color AverageColor (List cube) { - public List Colors { get; private set; } + var avgR = (byte)(cube.Average (c => c.R)); + var avgG = (byte)(cube.Average (c => c.G)); + var avgB = (byte)(cube.Average (c => c.B)); - public ColorBox (List colors) - { - Colors = colors; - } + return new Color (avgR, avgG, avgB); + } - // Get the range of brightness (luminance) in this box - public double GetBrightnessRange () + private int Volume (List cube) + { + if (cube == null || cube.Count == 0) { - double minBrightness = double.MaxValue, maxBrightness = double.MinValue; - - foreach (var color in Colors) - { - double brightness = GetBrightness (color); - if (brightness < minBrightness) - { - minBrightness = brightness; - } - - if (brightness > maxBrightness) - { - maxBrightness = brightness; - } - } - - return maxBrightness - minBrightness; + // Return a volume of 0 if the cube is empty or null + return 0; } - // 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; - } + 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); - // 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); } -} \ No newline at end of file +} diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index dfe9f63a4c..c8a50a54b4 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -7,6 +7,29 @@ namespace Terminal.Gui; /// 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 key. It moves drawing + cursor back to beginning of the current line + e.g. to draw more color layers. + + */ + + /// /// 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 string EncodeSixel (Color [,] pixels) 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 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/ diff --git a/UnitTests/Drawing/SixelEncoderTests.cs b/UnitTests/Drawing/SixelEncoderTests.cs index 6f36e8990e..3c80427108 100644 --- a/UnitTests/Drawing/SixelEncoderTests.cs +++ b/UnitTests/Drawing/SixelEncoderTests.cs @@ -37,6 +37,10 @@ public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel () 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); } From f8bb2f08b766d588441a382da6737ea30e0f8663 Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 15 Sep 2024 15:49:44 +0100 Subject: [PATCH 15/62] Fix infinite loop building palette --- .../Drawing/Quant/MedianCutPaletteBuilder.cs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs index cdafdfaa33..514865dc71 100644 --- a/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs +++ b/Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs @@ -1,4 +1,6 @@ -namespace Terminal.Gui; +using Terminal.Gui; +using Color = Terminal.Gui.Color; + public class MedianCutPaletteBuilder : IPaletteBuilder { private readonly IColorDistance _colorDistance; @@ -31,6 +33,14 @@ private List MedianCut (List colors, int maxColors) 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 ()) @@ -45,6 +55,7 @@ private List MedianCut (List colors, int maxColors) added = true; } + // Break the loop if no new cubes were added if (!added) { break; @@ -52,7 +63,14 @@ private List MedianCut (List colors, int maxColors) } // Calculate average color for each cube - return cubes.Select (AverageColor).Distinct().ToList (); + 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 From f40b7b46d5eeedb6f5a4b1a0d7d2cf5432a1854f Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 22 Sep 2024 20:28:39 +0100 Subject: [PATCH 16/62] Fix test and make comments clearer --- UnitTests/Drawing/SixelEncoderTests.cs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/UnitTests/Drawing/SixelEncoderTests.cs b/UnitTests/Drawing/SixelEncoderTests.cs index 3c80427108..ac0b92ccaf 100644 --- a/UnitTests/Drawing/SixelEncoderTests.cs +++ b/UnitTests/Drawing/SixelEncoderTests.cs @@ -17,10 +17,27 @@ public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel () var expected = "\u001bP" + // Start sixel sequence "0;0;0" + // Defaults for aspect ratio and grid size "q" + // Signals beginning of sixel image data - "\"1;1;3;2" + // no scaling factors (1x1) and filling 3 runes horizontally and 2 vertically + "\"1;1;12;2" + // no scaling factors (1x1) and filling 12px width with 2 'sixel' height = 12 px high + + /* + * Definition of the color palette + */ "#0;2;100;0;0" + // Red color definition in the format "#;;;;" - 2 means RGB. The values range 0 to 100 - "~~~~$-" + // First 6 rows of red pixels - "~~~~$-" + // Next 6 rows of red pixels + + /* + * Start of the Pixel data + * We draw 6 rows at once, so end up with 2 'lines' + * Both are basically the same and terminate with dollar hyphen (except last row) + * Format is: + * #0 (selects to use color palette index 0 i.e. red) + * !12 (repeat next byte 12 times i.e. the whole length of the row) + * ~ (the byte 111111 i.e. fill completely) + * $ (return to start of line) + * - (move down to next line) + */ + "#0!12~$-" + + "#0!12~$" + // Next 6 rows of red pixels + "\u001b\\"; // End sixel sequence // Arrange: Create a 12x12 bitmap filled with red @@ -44,5 +61,4 @@ public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel () Assert.Equal (expected, result); } - } From 93ce9a8b0bae454cd036d39d248a0bf2197e91b0 Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 22 Sep 2024 20:58:56 +0100 Subject: [PATCH 17/62] Add sixel test for grid 3x3 to make 12x12 checkerboard --- UnitTests/Drawing/SixelEncoderTests.cs | 103 +++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/UnitTests/Drawing/SixelEncoderTests.cs b/UnitTests/Drawing/SixelEncoderTests.cs index ac0b92ccaf..7608f113d4 100644 --- a/UnitTests/Drawing/SixelEncoderTests.cs +++ b/UnitTests/Drawing/SixelEncoderTests.cs @@ -61,4 +61,107 @@ public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel () Assert.Equal (expected, result); } + + [Fact] + public void EncodeSixel_12x12GridPattern3x3_ReturnsExpectedSixel () + { + /* + * Each block is a 3x3 square, alternating black and white. + * The pattern alternates between rows, creating a checkerboard. + * We have 4 blocks per row, and this repeats over 12x12 pixels. + + ███...███... + ███...███... + ███...███... + ...███...███ + ...███...███ + ...███...███ + ███...███... + ███...███... + ███...███... + ...███...███ + ...███...███ + ...███...███ + + Because we are dealing with sixels (drawing 6 rows at once) we will + see 2 bands being drawn. We will also see how we have to 'go back over' + the current line after drawing the black (so we can draw the white). + + */ + + + var expected = "\u001bP" + // Start sixel sequence + "0;0;0" + // Defaults for aspect ratio and grid size + "q" + // Signals beginning of sixel image data + "\"1;1;12;2" + // no scaling factors (1x1) and filling 12px width with 2 'sixel' height = 12 px high + + /* + * Definition of the color palette + */ + "#0;2;0;0;0" + // Black color definition (index 0: RGB 0,0,0) + "#1;2;100;100;100" + // White color definition (index 1: RGB 100,100,100) + + /* + * Start of the Pixel data + * + * Lets consider only the first 6 pixel (vertically). We have to fill the top 3 black and bottom 3 white. + * So we need to select black and fill 000111. To convert this into a character we must +63 and convert to ASCII + * Later on we will also need to select white and fill the inverse i.e. 111000. + * + * 111000 (binary) → w (ASCII 119). + * 000111 (binary) → F (ASCII 70). + * + * Therefore the lines become + * + * #0 (Select black) + * FFF (fill first 3 pixels horizontally - and top half of band black) + * www (fill next 3 pixels horizontally - bottom half of band black) + * FFFwww (as above to finish the line + * + * Next we must go back and fill the white (on the same band) + * #1 (Select white) + * + */ + "#0FFFwwwFFFwww$" + // First pass of top band (Filling black) + "#1wwwFFFwwwFFF$-" + // Second pass of top band (Filling white) + + // Sequence repeats exactly the same because top band is actually identical pixels to bottom band + "#0FFFwwwFFFwww$" + // First pass of bottom band (Filling white) + "#1wwwFFFwwwFFF$" + // Second pass of bottom band (Filling black) + + "\u001b\\"; // End sixel sequence + + // Arrange: Create a 12x12 bitmap with a 3x3 checkerboard pattern + var pixels = new Color [12, 12]; + for (int y = 0; y < 12; y++) + { + for (int x = 0; x < 12; x++) + { + // Create a 3x3 checkerboard by alternating the color based on pixel coordinates + if (((x / 3) + (y / 3)) % 2 == 0) + { + pixels [x, y] = new Color (0, 0, 0); // Black + } + else + { + pixels [x, y] = new Color (255, 255, 255); // White + } + } + } + + // Act: Encode the image + var encoder = new SixelEncoder (); // Assuming SixelEncoder is the class that contains the EncodeSixel method + string result = encoder.EncodeSixel (pixels); + + // We should have only black and white in the palette + Assert.Equal (2, encoder.Quantizer.Palette.Count); + Color black = encoder.Quantizer.Palette.ElementAt (0); + Color white = encoder.Quantizer.Palette.ElementAt(1); + + Assert.Equal (new Color (0, 0, 0), black); + Assert.Equal (new Color (255, 255, 255), white); + + // Compare the generated SIXEL string with the expected one + Assert.Equal (expected, result); + } } From ef56998f5aef7e458a3e72ddc5cd50c41dc45e9b Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 23 Sep 2024 19:51:05 +0100 Subject: [PATCH 18/62] Tidy up test file and comments in NetDriver --- Terminal.Gui/ConsoleDrivers/NetDriver.cs | 7 - UnitTests/Drawing/SixelEncoderTests.cs | 209 +++++++++++------------ 2 files changed, 97 insertions(+), 119 deletions(-) diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index 6914bbef4d..e23b52fcb2 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -1021,13 +1021,6 @@ public override void UpdateScreen () Console.Write (output); } - /* - // Hard-coded sixel content from the file - string knownGood = "\u001bP0;0;0q\"1;1;60;23#0;2;0;0;0#1;2;99;36;0#2;2;96;40;0#3;2;98;39;0#4;2;95;42;0#5;2;93;46;0#6;2;91;50;0#7;2;92;49;0#8;2;94;44;0#9;2;0;100;7#10;2;87;56;0#11;2;86;58;0#12;2;84;61;0#13;2;85;60;0#14;2;90;52;0#15;2;91;50;0#16;2;88;54;0#17;2;81;65;0#18;2;80;67;0#19;2;82;64;0#20;2;78;71;0#21;2;79;70;0#22;2;76;74;0#23;2;75;76;0#24;2;75;76;0#25;2;75;76;0#26;2;73;78;0#27;2;73;78;0#28;2;74;77;0#29;2;74;77;0#30;2;74;77;0#31;2;75;77;0#32;2;74;78;0#33;2;74;78;0#34;2;74;77;0#35;2;72;80;0#36;2;71;81;0#37;2;72;81;0#38;2;71;81;0#39;2;72;81;0#40;2;73;78;0#41;2;73;78;0#42;2;73;79;0#43;2;73;79;0#44;2;73;80;0#45;2;73;79;0#46;2;72;80;0#47;2;73;80;0#48;2;72;80;0#49;2;73;79;0#50;2;69;85;0#51;2;68;86;0#52;2;69;86;0#53;2;68;86;0#54;2;69;86;0#55;2;68;87;0#56;2;68;87;0#57;2;68;87;0#58;2;68;87;0#59;2;68;87;0#60;2;71;82;0#61;2;71;82;0#62;2;71;82;0#63;2;71;82;0#64;2;71;82;0#65;2;71;83;0#66;2;70;83;0#67;2;70;84;0#68;2;69;84;0#69;2;70;84;0#70;2;70;84;0#71;2;70;84;0#72;2;71;83;0#73;2;71;83;0#74;2;69;85;0#75;2;69;85;0#76;2;69;85;0#77;2;70;85;0#78;2;69;85;0#79;2;69;86;0#80;2;69;85;0#81;2;62;96;0#82;2;62;96;0#83;2;62;96;0#84;2;62;97;0#85;2;62;97;0#86;2;62;96;0#87;2;61;98;0#88;2;62;97;0#89;2;61;98;0#90;2;62;98;0#91;2;62;97;0#92;2;61;98;0#93;2;62;98;0#94;2;61;98;0#95;2;60;99;0#96;2;61;99;0#97;2;60;99;0#98;2;61;99;0#99;2;60;100;0#100;2;60;100;0#101;2;60;100;0#102;2;62;98;0#103;2;65;90;0#104;2;67;88;0#105;2;67;89;0#106;2;67;89;0#107;2;67;89;0#108;2;67;89;0#109;2;67;88;0#110;2;68;88;0#111;2;67;88;0#112;2;66;89;0#113;2;66;90;0#114;2;66;90;0#115;2;67;89;0#116;2;66;90;0#117;2;66;90;0#118;2;66;91;0#119;2;67;89;0#120;2;65;91;0#121;2;65;91;0#122;2;65;92;0#123;2;65;92;0#124;2;64;93;0#125;2;64;94;0#126;2;64;94;0#127;2;64;93;0#128;2;65;93;0#129;2;64;93;0#130;2;64;93;0#131;2;65;92;0#132;2;66;91;0#133;2;63;95;0#134;2;64;94;0#135;2;64;94;0#136;2;64;95;0#137;2;63;95;0#138;2;63;95;0#139;2;63;95;0#140;2;64;95;0#141;2;63;96;0#142;2;63;96;0#143;2;64;95;0#144;2;75;75;0#4~{w#7??B^}o#14F^_#10BFo_#13F~o#19?BN{#0{Ez|llJ}KsSsKw#37@FW#70BEw_#107?@Mo#121B]w#135@Mw#88BMo_$#8?BCo#6???@#15Nw_#16^{w#11N^w#12?N~{o#17BB#9wCAQQs?oGGGo#45F{o#65F{_#55?@Fw_#132?C#129@Co#141@Eo#87@#98N^~~$#5??BN~{_#18!17?@#22!5?@BB_#32B#26B#48?AG_#74?@F[o#110EO#120??_#124BMo#83@Ko$#23!33?B#72!7?W#52?AG#112?@Nw-#4@F^~w#5FN{o#15?F]o#16N~[_#13?D~w_#19@?{wo#18?Gw_#20Gw#22www_#32Fo_#37DMo#72C_#52@W_#110G_#121@Nw_#83?@^w_$#2EW_#8?Fwo#7BN~w_#10???b]o#12??F^}{#9@@AAAB?@AAA@#23[w#45F]o#65@Mo#74B]_#107@FWo#129?FW_#88??F[_$#3w_#14!9?@No#11??@Ny#0!4?BAEDDDCFEDDDEB#26?G#48@Io#70@J[_#55F]o#112FMo#135?BN}_#87?AO$#17!26?Gwo#21?Wo#124!21?CO#98???@N-#0MIyAyYmggWowGggwGgggGggG}I}wGgggWgggGw}A}??wKuyYYU{wGw?wG}I}$#1o_#4?@@@#8@A#7?@FFE#14?BE#10?BF#11F#13FFC#19?@@@C#18?@F#21FC#22?BFFE#23@#32@?o#37AA#70?@#74@@#55@#110@#107BE#121@F}#124@#135F#141@#83@#88@$#3@O#5!5?DFEG#15?@FC#16@FC#12!4?BF#17???BFE#20??BFC#144??@#45??@Nw#65@B#112!6?@C#129?@E$#9?CC{CcOOO_??oOO?oOOOoOOo?s??oOOO_OOOo??{!4?oGCccg??o???o?s$#2?@@#48!39?DC#120!8?A-#1^^OO#3OO#2O#4OOO#8O#5OO#7O^O#15O#14OO#10??O#11OO#13OO#12OOO#19OWO#18?OO#21O#20OO#22OOO#23O#32O#26O#45WO#48O#65OO#70O#74OO#55O#110O#112@O#121OO#129O#135O$#0??NGNK!4INNGN?NGN?NGNNGNGNNGN?NGGIIGNNGNNHNCLJJJGNFKJIJGNGN$#9???F?B!4D??F???F???F??F?F??F???FFDDF??F??E?BACCCF??BCCCF?F$#16!18?NOO#17!9?F?O#52!18?G#107??O-\u001b\\"; - - Application.Sixel = knownGood; - */ - if (!string.IsNullOrWhiteSpace(Application.Sixel)) { Console.SetCursorPosition (0,0); diff --git a/UnitTests/Drawing/SixelEncoderTests.cs b/UnitTests/Drawing/SixelEncoderTests.cs index 7608f113d4..12b1a90e22 100644 --- a/UnitTests/Drawing/SixelEncoderTests.cs +++ b/UnitTests/Drawing/SixelEncoderTests.cs @@ -1,52 +1,44 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Color = Terminal.Gui.Color; +using Color = Terminal.Gui.Color; namespace UnitTests.Drawing; public class SixelEncoderTests { - [Fact] public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel () { - - var expected = "\u001bP" + // Start sixel sequence - "0;0;0" + // Defaults for aspect ratio and grid size - "q" + // Signals beginning of sixel image data - "\"1;1;12;2" + // no scaling factors (1x1) and filling 12px width with 2 'sixel' height = 12 px high - - /* - * Definition of the color palette - */ - "#0;2;100;0;0" + // Red color definition in the format "#;;;;" - 2 means RGB. The values range 0 to 100 - - /* - * Start of the Pixel data - * We draw 6 rows at once, so end up with 2 'lines' - * Both are basically the same and terminate with dollar hyphen (except last row) - * Format is: - * #0 (selects to use color palette index 0 i.e. red) - * !12 (repeat next byte 12 times i.e. the whole length of the row) - * ~ (the byte 111111 i.e. fill completely) - * $ (return to start of line) - * - (move down to next line) - */ - "#0!12~$-" + - "#0!12~$" + // Next 6 rows of red pixels - - "\u001b\\"; // End sixel sequence + string expected = "\u001bP" // Start sixel sequence + + "0;0;0" // Defaults for aspect ratio and grid size + + "q" // Signals beginning of sixel image data + + "\"1;1;12;2" // no scaling factors (1x1) and filling 12px width with 2 'sixel' height = 12 px high + /* + * Definition of the color palette + * #;;;;" - 2 means RGB. The values range 0 to 100 + */ + + "#0;2;100;0;0" // Red color definition + /* + * Start of the Pixel data + * We draw 6 rows at once, so end up with 2 'lines' + * Both are basically the same and terminate with dollar hyphen (except last row) + * Format is: + * #0 (selects to use color palette index 0 i.e. red) + * !12 (repeat next byte 12 times i.e. the whole length of the row) + * ~ (the byte 111111 i.e. fill completely) + * $ (return to start of line) + * - (move down to next line) + */ + + "#0!12~$-" + + "#0!12~$" // Next 6 rows of red pixels + + "\u001b\\"; // End sixel sequence // Arrange: Create a 12x12 bitmap filled with red - var pixels = new Color [12, 12]; - for (int x = 0; x < 12; x++) + Color [,] pixels = new Color [12, 12]; + + for (var x = 0; x < 12; x++) { - for (int y = 0; y < 12; y++) + for (var y = 0; y < 12; y++) { - pixels [x, y] = new Color(255,0,0); + pixels [x, y] = new (255, 0, 0); } } @@ -57,8 +49,7 @@ public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel () // 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 (new (255, 0, 0), c1); Assert.Equal (expected, result); } @@ -66,85 +57,79 @@ public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel () public void EncodeSixel_12x12GridPattern3x3_ReturnsExpectedSixel () { /* - * Each block is a 3x3 square, alternating black and white. - * The pattern alternates between rows, creating a checkerboard. - * We have 4 blocks per row, and this repeats over 12x12 pixels. - - ███...███... - ███...███... - ███...███... - ...███...███ - ...███...███ - ...███...███ - ███...███... - ███...███... - ███...███... - ...███...███ - ...███...███ - ...███...███ - - Because we are dealing with sixels (drawing 6 rows at once) we will - see 2 bands being drawn. We will also see how we have to 'go back over' - the current line after drawing the black (so we can draw the white). - - */ - - - var expected = "\u001bP" + // Start sixel sequence - "0;0;0" + // Defaults for aspect ratio and grid size - "q" + // Signals beginning of sixel image data - "\"1;1;12;2" + // no scaling factors (1x1) and filling 12px width with 2 'sixel' height = 12 px high - - /* - * Definition of the color palette - */ - "#0;2;0;0;0" + // Black color definition (index 0: RGB 0,0,0) - "#1;2;100;100;100" + // White color definition (index 1: RGB 100,100,100) - - /* - * Start of the Pixel data - * - * Lets consider only the first 6 pixel (vertically). We have to fill the top 3 black and bottom 3 white. - * So we need to select black and fill 000111. To convert this into a character we must +63 and convert to ASCII - * Later on we will also need to select white and fill the inverse i.e. 111000. - * - * 111000 (binary) → w (ASCII 119). - * 000111 (binary) → F (ASCII 70). - * - * Therefore the lines become - * - * #0 (Select black) - * FFF (fill first 3 pixels horizontally - and top half of band black) - * www (fill next 3 pixels horizontally - bottom half of band black) - * FFFwww (as above to finish the line - * - * Next we must go back and fill the white (on the same band) - * #1 (Select white) - * - */ - "#0FFFwwwFFFwww$" + // First pass of top band (Filling black) - "#1wwwFFFwwwFFF$-" + // Second pass of top band (Filling white) - - // Sequence repeats exactly the same because top band is actually identical pixels to bottom band - "#0FFFwwwFFFwww$" + // First pass of bottom band (Filling white) - "#1wwwFFFwwwFFF$" + // Second pass of bottom band (Filling black) - - "\u001b\\"; // End sixel sequence + * Each block is a 3x3 square, alternating black and white. + * The pattern alternates between rows, creating a checkerboard. + * We have 4 blocks per row, and this repeats over 12x12 pixels. + * + * ███...███... + * ███...███... + * ███...███... + * ...███...███ + * ...███...███ + * ...███...███ + * ███...███... + * ███...███... + * ███...███... + * ...███...███ + * ...███...███ + * ...███...███ + * + * Because we are dealing with sixels (drawing 6 rows at once), we will + * see 2 bands being drawn. We will also see how we have to 'go back over' + * the current line after drawing the black (so we can draw the white). + */ + + string expected = "\u001bP" // Start sixel sequence + + "0;0;0" // Defaults for aspect ratio and grid size + + "q" // Signals beginning of sixel image data + + "\"1;1;12;2" // no scaling factors (1x1) and filling 12px width with 2 'sixel' height = 12 px high + /* + * Definition of the color palette + */ + + "#0;2;0;0;0" // Black color definition (index 0: RGB 0,0,0) + + "#1;2;100;100;100" // White color definition (index 1: RGB 100,100,100) + /* + * Start of the Pixel data + * + * Lets consider only the first 6 pixel (vertically). We have to fill the top 3 black and bottom 3 white. + * So we need to select black and fill 000111. To convert this into a character we must +63 and convert to ASCII. + * Later on we will also need to select white and fill the inverse, i.e. 111000. + * + * 111000 (binary) → w (ASCII 119). + * 000111 (binary) → F (ASCII 70). + * + * Therefore the lines become + * + * #0 (Select black) + * FFF (fill first 3 pixels horizontally - and top half of band black) + * www (fill next 3 pixels horizontally - bottom half of band black) + * FFFwww (as above to finish the line) + * + * Next we must go back and fill the white (on the same band) + * #1 (Select white) + */ + + "#0FFFwwwFFFwww$" // First pass of top band (Filling black) + + "#1wwwFFFwwwFFF$-" // Second pass of top band (Filling white) + // Sequence repeats exactly the same because top band is actually identical pixels to bottom band + + "#0FFFwwwFFFwww$" // First pass of bottom band (Filling black) + + "#1wwwFFFwwwFFF$" // Second pass of bottom band (Filling white) + + "\u001b\\"; // End sixel sequence // Arrange: Create a 12x12 bitmap with a 3x3 checkerboard pattern - var pixels = new Color [12, 12]; - for (int y = 0; y < 12; y++) + Color [,] pixels = new Color [12, 12]; + + for (var y = 0; y < 12; y++) { - for (int x = 0; x < 12; x++) + for (var x = 0; x < 12; x++) { // Create a 3x3 checkerboard by alternating the color based on pixel coordinates - if (((x / 3) + (y / 3)) % 2 == 0) + if ((x / 3 + y / 3) % 2 == 0) { - pixels [x, y] = new Color (0, 0, 0); // Black + pixels [x, y] = new (0, 0, 0); // Black } else { - pixels [x, y] = new Color (255, 255, 255); // White + pixels [x, y] = new (255, 255, 255); // White } } } @@ -156,10 +141,10 @@ the current line after drawing the black (so we can draw the white). // We should have only black and white in the palette Assert.Equal (2, encoder.Quantizer.Palette.Count); Color black = encoder.Quantizer.Palette.ElementAt (0); - Color white = encoder.Quantizer.Palette.ElementAt(1); + Color white = encoder.Quantizer.Palette.ElementAt (1); - Assert.Equal (new Color (0, 0, 0), black); - Assert.Equal (new Color (255, 255, 255), white); + Assert.Equal (new (0, 0, 0), black); + Assert.Equal (new (255, 255, 255), white); // Compare the generated SIXEL string with the expected one Assert.Equal (expected, result); From a7c65bf8b47234c9e7c0a1f0ef7c02de9f4f2e37 Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 23 Sep 2024 20:00:53 +0100 Subject: [PATCH 19/62] Move lab colors to UICatalog --- .../Drawing/Quant/CIE76ColorDistance.cs | 17 --- .../Drawing/Quant/CIE94ColorDistance.cs | 45 ------- .../Drawing/Quant/LabColorDistance.cs | 52 -------- UICatalog/Scenarios/Images.cs | 120 ++++++++++++++---- 4 files changed, 94 insertions(+), 140 deletions(-) delete mode 100644 Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs delete mode 100644 Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs delete mode 100644 Terminal.Gui/Drawing/Quant/LabColorDistance.cs diff --git a/Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs b/Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs deleted file mode 100644 index c2cc4d12e8..0000000000 --- a/Terminal.Gui/Drawing/Quant/CIE76ColorDistance.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Terminal.Gui; - -/// -/// This is the simplest method to measure color difference in the CIE Lab color space. The Euclidean distance in Lab -/// space is more aligned with human perception than RGB space, as Lab attempts to model how humans perceive color differences. -/// -public class CIE76ColorDistance : LabColorDistance -{ - public override double CalculateDistance (Color c1, Color c2) - { - var lab1 = RgbToLab (c1); - var lab2 = RgbToLab (c2); - - // Euclidean distance in Lab color space - return Math.Sqrt (Math.Pow (lab1.L - lab2.L, 2) + Math.Pow (lab1.A - lab2.A, 2) + Math.Pow (lab1.B - lab2.B, 2)); - } -} diff --git a/Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs b/Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs deleted file mode 100644 index b5103638fa..0000000000 --- a/Terminal.Gui/Drawing/Quant/CIE94ColorDistance.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace Terminal.Gui; - -/// -/// CIE94 improves on CIE76 by introducing adjustments for chroma (color intensity) and lightness. -/// This algorithm considers human visual perception more accurately by scaling differences in lightness and chroma. -/// It is better but slower than . -/// -public class CIE94ColorDistance : LabColorDistance -{ - // Constants for CIE94 formula (can be modified for different use cases like textiles or graphics) - private const double kL = 1.0; - private const double kC = 1.0; - private const double kH = 1.0; - - public override double CalculateDistance (Color first, Color second) - { - var lab1 = RgbToLab (first); - var lab2 = RgbToLab (second); - - // Delta L, A, B - double deltaL = lab1.L - lab2.L; - double deltaA = lab1.A - lab2.A; - double deltaB = lab1.B - lab2.B; - - // Chroma values for both colors - double c1 = Math.Sqrt (lab1.A * lab1.A + lab1.B * lab1.B); - double c2 = Math.Sqrt (lab2.A * lab2.A + lab2.B * lab2.B); - double deltaC = c1 - c2; - - // Delta H (calculated indirectly) - double deltaH = Math.Sqrt (Math.Pow (deltaA, 2) + Math.Pow (deltaB, 2) - Math.Pow (deltaC, 2)); - - // Scaling factors - double sL = 1.0; - double sC = 1.0 + 0.045 * c1; - double sH = 1.0 + 0.015 * c1; - - // CIE94 color difference formula - return Math.Sqrt ( - Math.Pow (deltaL / (kL * sL), 2) + - Math.Pow (deltaC / (kC * sC), 2) + - Math.Pow (deltaH / (kH * sH), 2) - ); - } -} diff --git a/Terminal.Gui/Drawing/Quant/LabColorDistance.cs b/Terminal.Gui/Drawing/Quant/LabColorDistance.cs deleted file mode 100644 index f1d97b5907..0000000000 --- a/Terminal.Gui/Drawing/Quant/LabColorDistance.cs +++ /dev/null @@ -1,52 +0,0 @@ -using ColorHelper; - -namespace Terminal.Gui; - -public abstract class LabColorDistance : IColorDistance -{ - // Reference white point for D65 illuminant (can be moved to constants) - private const double RefX = 95.047; - private const double RefY = 100.000; - private const double RefZ = 108.883; - - // Conversion from RGB to Lab - protected LabColor RgbToLab (Color c) - { - var xyz = ColorHelper.ColorConverter.RgbToXyz (new RGB (c.R, c.G, c.B)); - - // Normalize XYZ values by reference white point - double x = xyz.X / RefX; - double y = xyz.Y / RefY; - double z = xyz.Z / RefZ; - - // Apply the nonlinear transformation for Lab - x = x > 0.008856 ? Math.Pow (x, 1.0 / 3.0) : 7.787 * x + 16.0 / 116.0; - y = y > 0.008856 ? Math.Pow (y, 1.0 / 3.0) : 7.787 * y + 16.0 / 116.0; - z = z > 0.008856 ? Math.Pow (z, 1.0 / 3.0) : 7.787 * z + 16.0 / 116.0; - - // Calculate Lab values - double l = 116.0 * y - 16.0; - double a = 500.0 * (x - y); - double b = 200.0 * (y - z); - - return new LabColor (l, a, b); - } - - // LabColor class encapsulating L, A, and B values - protected class LabColor - { - public double L { get; } - public double A { get; } - public double B { get; } - - public LabColor (double l, double a, double b) - { - L = l; - A = a; - B = b; - } - } - - /// - public abstract double CalculateDistance (Color c1, Color c2); -} diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 8e50d89144..f929cabab1 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text; +using ColorHelper; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; @@ -20,7 +21,7 @@ public class Images : Scenario public override void Main () { Application.Init (); - var win = new Window { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName()}" }; + var win = new Window { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; bool canTrueColor = Application.Driver?.SupportsTrueColor ?? false; @@ -50,7 +51,7 @@ public override void Main () var btnOpenImage = new Button { X = Pos.Right (cbUseTrueColor) + 2, Y = 0, Text = "Open Image" }; win.Add (btnOpenImage); - + var imageView = new ImageView { X = 0, Y = Pos.Bottom (lblDriverName), Width = Dim.Fill (), Height = Dim.Fill () @@ -105,10 +106,8 @@ public override void Main () Application.Refresh (); }; - - - var btnSixel = new Button () { X = Pos.Right (btnOpenImage) + 2, Y = 0, Text = "Output Sixel" }; - btnSixel.Accept += (s, e) => { imageView.OutputSixel ();}; + var btnSixel = new Button { X = Pos.Right (btnOpenImage) + 2, Y = 0, Text = "Output Sixel" }; + btnSixel.Accept += (s, e) => { imageView.OutputSixel (); }; win.Add (btnSixel); Application.Run (win); @@ -116,7 +115,6 @@ public override void Main () Application.Shutdown (); } - private class ImageView : View { private readonly ConcurrentDictionary _cache = new (); @@ -147,10 +145,10 @@ public override void OnDrawContent (Rectangle bounds) Attribute attr = _cache.GetOrAdd ( rgb, - rgb => new Attribute ( - new Color (), - new Color (rgb.R, rgb.G, rgb.B) - ) + rgb => new ( + new Color (), + new Color (rgb.R, rgb.G, rgb.B) + ) ); Driver.SetAttribute (attr); @@ -174,18 +172,18 @@ public void OutputSixel () var encoder = new SixelEncoder (); - var encoded = encoder.EncodeSixel (ConvertToColorArray (_fullResImage)); + string encoded = encoder.EncodeSixel (ConvertToColorArray (_fullResImage)); var pv = new PaletteView (encoder.Quantizer.Palette.ToList ()); - var dlg = new Dialog () + var dlg = new Dialog { Title = "Palette (Esc to close)", Width = Dim.Fill (2), - Height = Dim.Fill (1), + Height = Dim.Fill (1) }; - var btn = new Button () + var btn = new Button { Text = "Ok" }; @@ -197,6 +195,7 @@ public void OutputSixel () Application.Sixel = encoded; } + public static Color [,] ConvertToColorArray (Image image) { int width = image.Width; @@ -204,18 +203,19 @@ public void OutputSixel () Color [,] colors = new Color [width, height]; // Loop through each pixel and convert Rgba32 to Terminal.Gui color - for (int x = 0; x < width; x++) + for (var x = 0; x < width; x++) { - for (int y = 0; y < height; y++) + for (var y = 0; y < height; y++) { - var pixel = image [x, y]; - colors [x, y] = new Color (pixel.R, pixel.G, pixel.B); // Convert Rgba32 to Terminal.Gui color + Rgba32 pixel = image [x, y]; + colors [x, y] = new (pixel.R, pixel.G, pixel.B); // Convert Rgba32 to Terminal.Gui color } } return colors; } } + public class PaletteView : View { private List _palette; @@ -231,20 +231,20 @@ public PaletteView (List palette) private (int columns, int rows) CalculateGridSize (Rectangle bounds) { // Characters are twice as wide as they are tall, so use 2:1 width-to-height ratio - int availableWidth = bounds.Width / 2; // Each color block is 2 character wide + int availableWidth = bounds.Width / 2; // Each color block is 2 character wide int availableHeight = bounds.Height; int numColors = _palette.Count; // Calculate the number of columns and rows we can fit within the bounds int columns = Math.Min (availableWidth, numColors); - int rows = (numColors + columns - 1) / columns; // Ceiling division for rows + int rows = (numColors + columns - 1) / columns; // Ceiling division for rows // Ensure we do not exceed the available height if (rows > availableHeight) { rows = availableHeight; - columns = (numColors + rows - 1) / rows; // Recalculate columns if needed + columns = (numColors + rows - 1) / rows; // Recalculate columns if needed } return (columns, rows); @@ -255,13 +255,15 @@ public override void OnDrawContent (Rectangle bounds) base.OnDrawContent (bounds); if (_palette == null || _palette.Count == 0) + { return; + } // Calculate the grid size based on the bounds - var (columns, rows) = CalculateGridSize (bounds); + (int columns, int rows) = CalculateGridSize (bounds); // Draw the colors in the palette - for (int i = 0; i < _palette.Count && i < columns * rows; i++) + for (var i = 0; i < _palette.Count && i < columns * rows; i++) { int row = i / columns; int col = i % columns; @@ -271,10 +273,10 @@ public override void OnDrawContent (Rectangle bounds) int y = row; // Set the color attribute for the block - Driver.SetAttribute (new Terminal.Gui.Attribute (_palette [i], _palette [i])); + Driver.SetAttribute (new (_palette [i], _palette [i])); // Draw the block (2 characters wide per block) - for (int dx = 0; dx < 2; dx++) // Fill the width of the block + for (var dx = 0; dx < 2; dx++) // Fill the width of the block { AddRune (x + dx, y, (Rune)' '); } @@ -289,3 +291,69 @@ public void SetPalette (List palette) } } } + +public abstract class LabColorDistance : IColorDistance +{ + // Reference white point for D65 illuminant (can be moved to constants) + private const double RefX = 95.047; + private const double RefY = 100.000; + private const double RefZ = 108.883; + + // Conversion from RGB to Lab + protected LabColor RgbToLab (Color c) + { + XYZ xyz = ColorConverter.RgbToXyz (new (c.R, c.G, c.B)); + + // Normalize XYZ values by reference white point + double x = xyz.X / RefX; + double y = xyz.Y / RefY; + double z = xyz.Z / RefZ; + + // Apply the nonlinear transformation for Lab + x = x > 0.008856 ? Math.Pow (x, 1.0 / 3.0) : 7.787 * x + 16.0 / 116.0; + y = y > 0.008856 ? Math.Pow (y, 1.0 / 3.0) : 7.787 * y + 16.0 / 116.0; + z = z > 0.008856 ? Math.Pow (z, 1.0 / 3.0) : 7.787 * z + 16.0 / 116.0; + + // Calculate Lab values + double l = 116.0 * y - 16.0; + double a = 500.0 * (x - y); + double b = 200.0 * (y - z); + + return new (l, a, b); + } + + // LabColor class encapsulating L, A, and B values + protected class LabColor + { + public double L { get; } + public double A { get; } + public double B { get; } + + public LabColor (double l, double a, double b) + { + L = l; + A = a; + B = b; + } + } + + /// + public abstract double CalculateDistance (Color c1, Color c2); +} + +/// +/// This is the simplest method to measure color difference in the CIE Lab color space. The Euclidean distance in Lab +/// space is more aligned with human perception than RGB space, as Lab attempts to model how humans perceive color +/// differences. +/// +public class CIE76ColorDistance : LabColorDistance +{ + public override double CalculateDistance (Color c1, Color c2) + { + LabColor lab1 = RgbToLab (c1); + LabColor lab2 = RgbToLab (c2); + + // Euclidean distance in Lab color space + return Math.Sqrt (Math.Pow (lab1.L - lab2.L, 2) + Math.Pow (lab1.A - lab2.A, 2) + Math.Pow (lab1.B - lab2.B, 2)); + } +} From f07ab92dca24649875438eb6beaf6c8236f64fbe Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 23 Sep 2024 20:31:05 +0100 Subject: [PATCH 20/62] Switch to simpler and faster palette builder --- Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 8 +- .../Drawing/Quant/EuclideanColorDistance.cs | 13 ++ .../Drawing/Quant/MedianCutPaletteBuilder.cs | 141 ----------------- .../Quant/PopularityPaletteWithThreshold.cs | 106 +++++++++++++ UICatalog/Scenarios/Images.cs | 147 ++++++++++++++++++ .../PopularityPaletteWithThresholdTests.cs | 118 ++++++++++++++ 6 files changed, 388 insertions(+), 145 deletions(-) delete mode 100644 Terminal.Gui/Drawing/Quant/MedianCutPaletteBuilder.cs create mode 100644 Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs create mode 100644 UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index 4b32e1669e..d6196a9467 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 49d754ed40..e04a63972c 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 514865dc71..0000000000 --- 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 0000000000..2f767b4c25 --- /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 f929cabab1..4c47239bc9 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -357,3 +357,150 @@ public override double CalculateDistance (Color c1, Color c2) 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 0000000000..7de04c652b --- /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); + } +} From 23785700152b653071b5f21f0d5c0e07b965e479 Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 23 Sep 2024 20:41:26 +0100 Subject: [PATCH 21/62] Fix early exit bug in palette builder and change to far more conservative threshold --- Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 2 +- Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index d6196a9467..51071b2a57 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -28,7 +28,7 @@ public class ColorQuantizer /// /// Gets or sets the algorithm used to build the . /// - public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (),50) ; + public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (),5) ; public void BuildPalette (Color [,] pixels) { diff --git a/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs index 2f767b4c25..9e856b96c4 100644 --- a/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs +++ b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs @@ -93,9 +93,8 @@ private Dictionary MergeSimilarColors (Dictionary colorH mergedHistogram [currentColor] = entry.Value; } - // Early exit if we've reduced the colors to the maxColors limit - if (mergedHistogram.Count <= maxColors) + if (mergedHistogram.Count >= maxColors) { return mergedHistogram; } From 4571978a9b84f02886bfee539d07a020454e24d6 Mon Sep 17 00:00:00 2001 From: tznind Date: Wed, 25 Sep 2024 18:59:17 +0100 Subject: [PATCH 22/62] Fix fill area - y is not in sixels its in pixels --- Terminal.Gui/Drawing/SixelEncoder.cs | 15 ++------------- UnitTests/Drawing/SixelEncoderTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index c8a50a54b4..65924bb4c6 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -204,20 +204,9 @@ private string GetColorPalette (Color [,] pixels) private string GetFillArea (Color [,] pixels) { - int widthInChars = GetWidthInChars (pixels); - int heightInChars = GetHeightInChars (pixels); + int widthInChars = pixels.GetLength (0); + int heightInChars = pixels.GetLength (1); return $"{widthInChars};{heightInChars}"; } - - private int GetHeightInChars (Color [,] pixels) - { - int height = pixels.GetLength (1); - return (height + 5) / 6; - } - - private int GetWidthInChars (Color [,] pixels) - { - return pixels.GetLength (0); - } } \ No newline at end of file diff --git a/UnitTests/Drawing/SixelEncoderTests.cs b/UnitTests/Drawing/SixelEncoderTests.cs index 12b1a90e22..3e84f5b8ca 100644 --- a/UnitTests/Drawing/SixelEncoderTests.cs +++ b/UnitTests/Drawing/SixelEncoderTests.cs @@ -10,7 +10,7 @@ public void EncodeSixel_RedSquare12x12_ReturnsExpectedSixel () string expected = "\u001bP" // Start sixel sequence + "0;0;0" // Defaults for aspect ratio and grid size + "q" // Signals beginning of sixel image data - + "\"1;1;12;2" // no scaling factors (1x1) and filling 12px width with 2 'sixel' height = 12 px high + + "\"1;1;12;12" // no scaling factors (1x1) and filling 12x12 pixel area /* * Definition of the color palette * #;;;;" - 2 means RGB. The values range 0 to 100 @@ -82,7 +82,7 @@ public void EncodeSixel_12x12GridPattern3x3_ReturnsExpectedSixel () string expected = "\u001bP" // Start sixel sequence + "0;0;0" // Defaults for aspect ratio and grid size + "q" // Signals beginning of sixel image data - + "\"1;1;12;2" // no scaling factors (1x1) and filling 12px width with 2 'sixel' height = 12 px high + + "\"1;1;12;12" // no scaling factors (1x1) and filling 12x12 pixel area /* * Definition of the color palette */ From 3c6804aee72da032cbdb2755c7373b6eddfdf20a Mon Sep 17 00:00:00 2001 From: tznind Date: Wed, 25 Sep 2024 19:15:30 +0100 Subject: [PATCH 23/62] Move license to top of page and credit both source repos --- Terminal.Gui/Drawing/SixelEncoder.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index 65924bb4c6..bccc2f8fc6 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -1,7 +1,14 @@ -using Terminal.Gui; +// This code is based on existing implementations of sixel algorithm in MIT licensed open source libraries +// node-sixel (Typescript) - https://github.com/jerch/node-sixel/tree/master/src +// Copyright (c) 2019, Joerg Breitbart @license MIT +// libsixel (C/C++) - https://github.com/saitoha/libsixel +// Copyright (c) 2014-2016 Hayaki Saito @license MIT + +using Terminal.Gui; namespace Terminal.Gui; + /// /// Encodes a images into the sixel console image output format. /// @@ -62,13 +69,6 @@ public string EncodeSixel (Color [,] pixels) return start + defaultRatios + completeStartSequence + noScaling + fillArea + pallette + pixelData + terminator; } - /** - * This method is adapted from - * https://github.com/jerch/node-sixel/ - * - * Copyright (c) 2019 Joerg Breitbart. - * @license MIT - */ private string WriteSixel (Color [,] pixels) { From 6149c5f5d3dd53c64c61f514c9286369438ca493 Mon Sep 17 00:00:00 2001 From: tznind Date: Thu, 26 Sep 2024 19:34:06 +0100 Subject: [PATCH 24/62] Add 2 tabs to Image scenario - one for sixel one for basic --- UICatalog/Scenarios/Images.cs | 132 ++++++++++++++++++++++------------ 1 file changed, 87 insertions(+), 45 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 4c47239bc9..aff9e50cdb 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel; using System.IO; using System.Linq; using System.Text; @@ -18,6 +19,7 @@ namespace UICatalog.Scenarios; [ScenarioCategory ("Drawing")] public class Images : Scenario { + private ImageView _imageView; public override void Main () { Application.Init (); @@ -25,6 +27,18 @@ public override void Main () bool canTrueColor = Application.Driver?.SupportsTrueColor ?? false; + + var tabBasic = new Tab () + { + DisplayText = "Basic" + }; + + var tabSixel = new Tab () + { + DisplayText = "Sixel" + }; + + var lblDriverName = new Label { X = 0, Y = 0, Text = $"Driver is {Application.Driver?.GetType ().Name}" }; win.Add (lblDriverName); @@ -52,67 +66,95 @@ public override void Main () var btnOpenImage = new Button { X = Pos.Right (cbUseTrueColor) + 2, Y = 0, Text = "Open Image" }; win.Add (btnOpenImage); - var imageView = new ImageView + + var tv = new TabView { - X = 0, Y = Pos.Bottom (lblDriverName), Width = Dim.Fill (), Height = Dim.Fill () + Y = Pos.Bottom (lblDriverName), Width = Dim.Fill (), Height = Dim.Fill () }; - win.Add (imageView); - btnOpenImage.Accept += (_, _) => - { - var ofd = new OpenDialog { Title = "Open Image", AllowsMultipleSelection = false }; - Application.Run (ofd); + tv.AddTab (tabBasic, true); + tv.AddTab (tabSixel, false); - if (ofd.Path is { }) - { - Directory.SetCurrentDirectory (Path.GetFullPath (Path.GetDirectoryName (ofd.Path)!)); - } + BuildBasicTab (tabBasic); + BuildSixelTab (tabSixel); - if (ofd.Canceled) - { - ofd.Dispose (); + btnOpenImage.Accept += OpenImage; - return; - } + win.Add (tv); + Application.Run (win); + win.Dispose (); + Application.Shutdown (); + } - string path = ofd.FilePaths [0]; + private void OpenImage (object sender, HandledEventArgs e) + { + var ofd = new OpenDialog { Title = "Open Image", AllowsMultipleSelection = false }; + Application.Run (ofd); - ofd.Dispose (); + if (ofd.Path is { }) + { + Directory.SetCurrentDirectory (Path.GetFullPath (Path.GetDirectoryName (ofd.Path)!)); + } - if (string.IsNullOrWhiteSpace (path)) - { - return; - } + if (ofd.Canceled) + { + ofd.Dispose (); - if (!File.Exists (path)) - { - return; - } + return; + } - Image img; + string path = ofd.FilePaths [0]; - try - { - img = Image.Load (File.ReadAllBytes (path)); - } - catch (Exception ex) - { - MessageBox.ErrorQuery ("Could not open file", ex.Message, "Ok"); + ofd.Dispose (); - return; - } + if (string.IsNullOrWhiteSpace (path)) + { + return; + } - imageView.SetImage (img); - Application.Refresh (); - }; + if (!File.Exists (path)) + { + return; + } - var btnSixel = new Button { X = Pos.Right (btnOpenImage) + 2, Y = 0, Text = "Output Sixel" }; - btnSixel.Accept += (s, e) => { imageView.OutputSixel (); }; - win.Add (btnSixel); + Image img; - Application.Run (win); - win.Dispose (); - Application.Shutdown (); + try + { + img = Image.Load (File.ReadAllBytes (path)); + } + catch (Exception ex) + { + MessageBox.ErrorQuery ("Could not open file", ex.Message, "Ok"); + + return; + } + + _imageView.SetImage (img); + Application.Refresh (); + } + + private void BuildBasicTab (Tab tabBasic) + { + _imageView = new ImageView + { + Width = Dim.Fill(), + Height = Dim.Fill() + }; + + tabBasic.View = _imageView; + } + + private void BuildSixelTab (Tab tabSixel) + { + tabSixel.View = new View () + { + Width = Dim.Fill (), + Height = Dim.Fill () + }; + var btnSixel = new Button { X = 0, Y = 0, Text = "Output Sixel" }; + btnSixel.Accept += (s, e) => { _imageView.OutputSixel (); }; + tabSixel.View.Add (btnSixel); } private class ImageView : View From 9322b9af7cbc0a816783f60aded571e66c6a5431 Mon Sep 17 00:00:00 2001 From: tznind Date: Thu, 26 Sep 2024 20:33:37 +0100 Subject: [PATCH 25/62] Output at specific position --- .../Application/Application.Driver.cs | 2 +- Terminal.Gui/ConsoleDrivers/NetDriver.cs | 9 +- Terminal.Gui/Drawing/SixelEncoder.cs | 1 - Terminal.Gui/Drawing/SixelToRender.cs | 19 +++ UICatalog/Scenarios/Images.cs | 128 ++++++++++++++---- 5 files changed, 131 insertions(+), 28 deletions(-) create mode 100644 Terminal.Gui/Drawing/SixelToRender.cs diff --git a/Terminal.Gui/Application/Application.Driver.cs b/Terminal.Gui/Application/Application.Driver.cs index 7b0166d021..0518dfdb5f 100644 --- a/Terminal.Gui/Application/Application.Driver.cs +++ b/Terminal.Gui/Application/Application.Driver.cs @@ -27,5 +27,5 @@ public static partial class Application // Driver abstractions [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] public static string ForceDriver { get; set; } = string.Empty; - public static string Sixel; + public static List Sixel = new List (); } diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index f0508fe75a..6d49d62793 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -1021,10 +1021,13 @@ public override void UpdateScreen () Console.Write (output); } - if (!string.IsNullOrWhiteSpace(Application.Sixel)) + foreach (var s in Application.Sixel) { - Console.SetCursorPosition (0,0); - Console.Write (Application.Sixel); + if (!string.IsNullOrWhiteSpace (s.SixelData)) + { + SetCursorPosition (s.ScreenPosition.X, s.ScreenPosition.Y); + Console.Write (s.SixelData); + } } } diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index bccc2f8fc6..5929752dfe 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -8,7 +8,6 @@ namespace Terminal.Gui; - /// /// Encodes a images into the sixel console image output format. /// diff --git a/Terminal.Gui/Drawing/SixelToRender.cs b/Terminal.Gui/Drawing/SixelToRender.cs new file mode 100644 index 0000000000..dc002c7efb --- /dev/null +++ b/Terminal.Gui/Drawing/SixelToRender.cs @@ -0,0 +1,19 @@ +namespace Terminal.Gui; + +/// +/// Describes a request to render a given at a given . +/// Requires that the terminal and both support sixel. +/// +public class SixelToRender +{ + /// + /// gets or sets the encoded sixel data. Use to convert bitmaps + /// into encoded sixel data. + /// + public string SixelData { get; set; } + + /// + /// gets or sets where to move the cursor to before outputting the . + /// + public Point ScreenPosition { get; set; } +} diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index aff9e50cdb..d8681610bd 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -20,6 +20,7 @@ namespace UICatalog.Scenarios; public class Images : Scenario { private ImageView _imageView; + public override void Main () { Application.Init (); @@ -27,18 +28,16 @@ public override void Main () bool canTrueColor = Application.Driver?.SupportsTrueColor ?? false; - - var tabBasic = new Tab () + var tabBasic = new Tab { DisplayText = "Basic" }; - var tabSixel = new Tab () + var tabSixel = new Tab { DisplayText = "Sixel" }; - var lblDriverName = new Label { X = 0, Y = 0, Text = $"Driver is {Application.Driver?.GetType ().Name}" }; win.Add (lblDriverName); @@ -66,7 +65,6 @@ public override void Main () var btnOpenImage = new Button { X = Pos.Right (cbUseTrueColor) + 2, Y = 0, Text = "Open Image" }; win.Add (btnOpenImage); - var tv = new TabView { Y = Pos.Bottom (lblDriverName), Width = Dim.Fill (), Height = Dim.Fill () @@ -136,10 +134,10 @@ private void OpenImage (object sender, HandledEventArgs e) private void BuildBasicTab (Tab tabBasic) { - _imageView = new ImageView + _imageView = new() { - Width = Dim.Fill(), - Height = Dim.Fill() + Width = Dim.Fill (), + Height = Dim.Fill () }; tabBasic.View = _imageView; @@ -147,14 +145,64 @@ private void BuildBasicTab (Tab tabBasic) private void BuildSixelTab (Tab tabSixel) { - tabSixel.View = new View () + tabSixel.View = new() { Width = Dim.Fill (), Height = Dim.Fill () }; - var btnSixel = new Button { X = 0, Y = 0, Text = "Output Sixel" }; - btnSixel.Accept += (s, e) => { _imageView.OutputSixel (); }; + + var btnSixel = new Button { X = 0, Y = 0, Text = "Output Sixel", Width = Dim.Auto () }; tabSixel.View.Add (btnSixel); + + var sixelView = new View + { + Y = Pos.Bottom (btnSixel), + Width = Dim.Percent (50), + Height = Dim.Fill (), + BorderStyle = LineStyle.Dotted + }; + + tabSixel.View.Add (sixelView); + + var lblPxX = new Label + { + X = Pos.Right (sixelView), + Text = "Pixels per Col:" + }; + + var pxX = new NumericUpDown + { + X = Pos.Right (lblPxX), + Value = 12 + }; + + var lblPxY = new Label + { + X = lblPxX.X, + Y = 1, + Text = "Pixels per Row:" + }; + + var pxY = new NumericUpDown + { + X = Pos.Right (lblPxY), + Y = 1, + Value = 6 + }; + + tabSixel.View.Add (lblPxX); + tabSixel.View.Add (pxX); + tabSixel.View.Add (lblPxY); + tabSixel.View.Add (pxY); + + btnSixel.Accept += (s, e) => + { + _imageView.OutputSixel ( + sixelView.FrameToScreen ().Location, + sixelView.Frame.Size, + pxX.Value, + pxY.Value); + }; } private class ImageView : View @@ -205,7 +253,12 @@ internal void SetImage (Image image) SetNeedsDisplay (); } - public void OutputSixel () + public void OutputSixel ( + Point screenPosition, + Size maxSize, + int pixelsPerCellX, + int pixelsPerCellY + ) { if (_fullResImage == null) { @@ -214,7 +267,21 @@ public void OutputSixel () var encoder = new SixelEncoder (); - string encoded = encoder.EncodeSixel (ConvertToColorArray (_fullResImage)); + // Calculate the target size in pixels based on console units + int targetWidthInPixels = maxSize.Width * pixelsPerCellX; + int targetHeightInPixels = maxSize.Height * pixelsPerCellY; + + // Get the original image dimensions + int originalWidth = _fullResImage.Width; + int originalHeight = _fullResImage.Height; + + // Use the helper function to get the resized dimensions while maintaining the aspect ratio + Size newSize = CalculateAspectRatioFit (originalWidth, originalHeight, targetWidthInPixels, targetHeightInPixels); + + // Resize the image to match the console size + Image resizedImage = _fullResImage.Clone (x => x.Resize (newSize.Width, newSize.Height)); + + string encoded = encoder.EncodeSixel (ConvertToColorArray (resizedImage)); var pv = new PaletteView (encoder.Quantizer.Palette.ToList ()); @@ -235,7 +302,29 @@ public void OutputSixel () dlg.AddButton (btn); Application.Run (dlg); - Application.Sixel = encoded; + Application.Sixel.Add ( + new() + { + ScreenPosition = screenPosition, + SixelData = encoded + }); + } + + private Size CalculateAspectRatioFit (int originalWidth, int originalHeight, int targetWidth, int targetHeight) + { + // Calculate the scaling factor for width and height + double widthScale = (double)targetWidth / originalWidth; + double heightScale = (double)targetHeight / originalHeight; + + // Use the smaller scaling factor to maintain the aspect ratio + double scale = Math.Min (widthScale, heightScale); + + // Calculate the new width and height while keeping the aspect ratio + var newWidth = (int)(originalWidth * scale); + var newHeight = (int)(originalHeight * scale); + + // Return the new size as a Size object + return new (newWidth, newHeight); } public static Color [,] ConvertToColorArray (Image image) @@ -260,7 +349,7 @@ public void OutputSixel () public class PaletteView : View { - private List _palette; + private readonly List _palette; public PaletteView (List palette) { @@ -324,13 +413,6 @@ public override void OnDrawContent (Rectangle bounds) } } } - - // Allows dynamically changing the palette - public void SetPalette (List palette) - { - _palette = palette ?? new List (); - SetNeedsDisplay (); - } } } @@ -418,7 +500,7 @@ public List BuildPalette (List colors, int maxColors) private List MedianCut (List colors, int maxColors) { - List> cubes = new() { colors }; + List> cubes = new () { colors }; // Recursively split color regions while (cubes.Count < maxColors) From 519d8c0fcda2efb67dca2132001de1ac3c1989d5 Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 28 Sep 2024 10:22:25 +0100 Subject: [PATCH 26/62] Investigate changing sixel to output as part of view render --- UICatalog/Scenarios/Images.cs | 220 ++++++++++++++++++---------------- 1 file changed, 119 insertions(+), 101 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index d8681610bd..9669e4c2c3 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -20,6 +20,8 @@ namespace UICatalog.Scenarios; public class Images : Scenario { private ImageView _imageView; + private Point _screenLocationForSixel; + private string _encodedSixelData; public override void Main () { @@ -195,27 +197,134 @@ private void BuildSixelTab (Tab tabSixel) tabSixel.View.Add (lblPxY); tabSixel.View.Add (pxY); + sixelView.DrawContent += SixelViewOnDrawContent; + + btnSixel.Accept += (s, e) => { - _imageView.OutputSixel ( - sixelView.FrameToScreen ().Location, - sixelView.Frame.Size, - pxX.Value, - pxY.Value); + + if (_imageView.FullResImage == null) + { + return; + } + + + _screenLocationForSixel = sixelView.FrameToScreen ().Location; + _encodedSixelData = GenerateSixelData( + _imageView.FullResImage, + sixelView.Frame.Size, + pxX.Value, + pxY.Value); }; } + void SixelViewOnDrawContent (object sender, DrawEventArgs e) + { + if (!string.IsNullOrWhiteSpace (_encodedSixelData)) + { + // Does not work + Application.Driver?.Move (_screenLocationForSixel.X, _screenLocationForSixel.Y); + Application.Driver?.AddStr (_encodedSixelData); + + // Works in NetDriver but results in screen flicker when moving mouse but vanish instantly + // Console.SetCursorPosition (_screenLocationForSixel.X, _screenLocationForSixel.Y); + // Console.Write (_encodedSixelData); + } + } + + public string GenerateSixelData( + Image fullResImage, + Size maxSize, + int pixelsPerCellX, + int pixelsPerCellY + ) + { + var encoder = new SixelEncoder (); + + // Calculate the target size in pixels based on console units + int targetWidthInPixels = maxSize.Width * pixelsPerCellX; + int targetHeightInPixels = maxSize.Height * pixelsPerCellY; + + // Get the original image dimensions + int originalWidth = fullResImage.Width; + int originalHeight = fullResImage.Height; + + // Use the helper function to get the resized dimensions while maintaining the aspect ratio + Size newSize = CalculateAspectRatioFit (originalWidth, originalHeight, targetWidthInPixels, targetHeightInPixels); + + // Resize the image to match the console size + Image resizedImage = fullResImage.Clone (x => x.Resize (newSize.Width, newSize.Height)); + + string encoded = encoder.EncodeSixel (ConvertToColorArray (resizedImage)); + + var pv = new PaletteView (encoder.Quantizer.Palette.ToList ()); + + var dlg = new Dialog + { + Title = "Palette (Esc to close)", + Width = Dim.Fill (2), + Height = Dim.Fill (1) + }; + + var btn = new Button + { + Text = "Ok" + }; + + btn.Accept += (s, e) => Application.RequestStop (); + dlg.Add (pv); + dlg.AddButton (btn); + Application.Run (dlg); + + return encoded; + } + + private Size CalculateAspectRatioFit (int originalWidth, int originalHeight, int targetWidth, int targetHeight) + { + // Calculate the scaling factor for width and height + double widthScale = (double)targetWidth / originalWidth; + double heightScale = (double)targetHeight / originalHeight; + + // Use the smaller scaling factor to maintain the aspect ratio + double scale = Math.Min (widthScale, heightScale); + + // Calculate the new width and height while keeping the aspect ratio + var newWidth = (int)(originalWidth * scale); + var newHeight = (int)(originalHeight * scale); + + // Return the new size as a Size object + return new (newWidth, newHeight); + } + + public static Color [,] ConvertToColorArray (Image image) + { + int width = image.Width; + int height = image.Height; + Color [,] colors = new Color [width, height]; + + // Loop through each pixel and convert Rgba32 to Terminal.Gui color + for (var x = 0; x < width; x++) + { + for (var y = 0; y < height; y++) + { + Rgba32 pixel = image [x, y]; + colors [x, y] = new (pixel.R, pixel.G, pixel.B); // Convert Rgba32 to Terminal.Gui color + } + } + + return colors; + } private class ImageView : View { private readonly ConcurrentDictionary _cache = new (); - private Image _fullResImage; + public Image FullResImage; private Image _matchSize; public override void OnDrawContent (Rectangle bounds) { base.OnDrawContent (bounds); - if (_fullResImage == null) + if (FullResImage == null) { return; } @@ -224,7 +333,7 @@ public override void OnDrawContent (Rectangle bounds) if (_matchSize == null || bounds.Width != _matchSize.Width || bounds.Height != _matchSize.Height) { // generate one - _matchSize = _fullResImage.Clone (x => x.Resize (bounds.Width, bounds.Height)); + _matchSize = FullResImage.Clone (x => x.Resize (bounds.Width, bounds.Height)); } for (var y = 0; y < bounds.Height; y++) @@ -249,102 +358,11 @@ public override void OnDrawContent (Rectangle bounds) internal void SetImage (Image image) { - _fullResImage = image; + FullResImage = image; SetNeedsDisplay (); } - public void OutputSixel ( - Point screenPosition, - Size maxSize, - int pixelsPerCellX, - int pixelsPerCellY - ) - { - if (_fullResImage == null) - { - return; - } - - var encoder = new SixelEncoder (); - - // Calculate the target size in pixels based on console units - int targetWidthInPixels = maxSize.Width * pixelsPerCellX; - int targetHeightInPixels = maxSize.Height * pixelsPerCellY; - - // Get the original image dimensions - int originalWidth = _fullResImage.Width; - int originalHeight = _fullResImage.Height; - - // Use the helper function to get the resized dimensions while maintaining the aspect ratio - Size newSize = CalculateAspectRatioFit (originalWidth, originalHeight, targetWidthInPixels, targetHeightInPixels); - - // Resize the image to match the console size - Image resizedImage = _fullResImage.Clone (x => x.Resize (newSize.Width, newSize.Height)); - - string encoded = encoder.EncodeSixel (ConvertToColorArray (resizedImage)); - - var pv = new PaletteView (encoder.Quantizer.Palette.ToList ()); - - var dlg = new Dialog - { - Title = "Palette (Esc to close)", - Width = Dim.Fill (2), - Height = Dim.Fill (1) - }; - - var btn = new Button - { - Text = "Ok" - }; - - btn.Accept += (s, e) => Application.RequestStop (); - dlg.Add (pv); - dlg.AddButton (btn); - Application.Run (dlg); - - Application.Sixel.Add ( - new() - { - ScreenPosition = screenPosition, - SixelData = encoded - }); - } - - private Size CalculateAspectRatioFit (int originalWidth, int originalHeight, int targetWidth, int targetHeight) - { - // Calculate the scaling factor for width and height - double widthScale = (double)targetWidth / originalWidth; - double heightScale = (double)targetHeight / originalHeight; - - // Use the smaller scaling factor to maintain the aspect ratio - double scale = Math.Min (widthScale, heightScale); - - // Calculate the new width and height while keeping the aspect ratio - var newWidth = (int)(originalWidth * scale); - var newHeight = (int)(originalHeight * scale); - - // Return the new size as a Size object - return new (newWidth, newHeight); - } - - public static Color [,] ConvertToColorArray (Image image) - { - int width = image.Width; - int height = image.Height; - Color [,] colors = new Color [width, height]; - - // Loop through each pixel and convert Rgba32 to Terminal.Gui color - for (var x = 0; x < width; x++) - { - for (var y = 0; y < height; y++) - { - Rgba32 pixel = image [x, y]; - colors [x, y] = new (pixel.R, pixel.G, pixel.B); // Convert Rgba32 to Terminal.Gui color - } - } - - return colors; - } + } public class PaletteView : View From 94cbc1c9b0f7f35114320c560c3b91b9d8cdcb98 Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 28 Sep 2024 10:41:33 +0100 Subject: [PATCH 27/62] Restore the static approach to rendering for now and fix dispose --- UICatalog/Scenarios/Images.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 9669e4c2c3..08c10d04ea 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -215,15 +215,22 @@ private void BuildSixelTab (Tab tabSixel) sixelView.Frame.Size, pxX.Value, pxY.Value); + + // TODO: Static way of doing this, suboptimal + Application.Sixel.Add (new SixelToRender + { + SixelData = _encodedSixelData, + ScreenPosition = _screenLocationForSixel + }); }; } void SixelViewOnDrawContent (object sender, DrawEventArgs e) { if (!string.IsNullOrWhiteSpace (_encodedSixelData)) { - // Does not work - Application.Driver?.Move (_screenLocationForSixel.X, _screenLocationForSixel.Y); - Application.Driver?.AddStr (_encodedSixelData); + // Does not work (see https://github.com/gui-cs/Terminal.Gui/issues/3763) + // Application.Driver?.Move (_screenLocationForSixel.X, _screenLocationForSixel.Y); + // Application.Driver?.AddStr (_encodedSixelData); // Works in NetDriver but results in screen flicker when moving mouse but vanish instantly // Console.SetCursorPosition (_screenLocationForSixel.X, _screenLocationForSixel.Y); @@ -274,6 +281,7 @@ int pixelsPerCellY dlg.Add (pv); dlg.AddButton (btn); Application.Run (dlg); + dlg.Dispose (); return encoded; } From d16f1b65b96e50268546125d2df9aa5f0ae9ba8d Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 28 Sep 2024 10:46:38 +0100 Subject: [PATCH 28/62] Fix tabbing into sixel tab view --- UICatalog/Scenarios/Images.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 08c10d04ea..5e996e4bd2 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -139,7 +139,9 @@ private void BuildBasicTab (Tab tabBasic) _imageView = new() { Width = Dim.Fill (), - Height = Dim.Fill () + Height = Dim.Fill (), + CanFocus = true + }; tabBasic.View = _imageView; @@ -150,7 +152,8 @@ private void BuildSixelTab (Tab tabSixel) tabSixel.View = new() { Width = Dim.Fill (), - Height = Dim.Fill () + Height = Dim.Fill (), + CanFocus = true }; var btnSixel = new Button { X = 0, Y = 0, Text = "Output Sixel", Width = Dim.Auto () }; From 18f185d7fba74cf39d0717719b0375a007ac2298 Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 28 Sep 2024 11:00:15 +0100 Subject: [PATCH 29/62] Fix scenario dispose --- UICatalog/Scenarios/Images.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 5e996e4bd2..cdab42e3c0 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -84,6 +84,14 @@ public override void Main () Application.Run (win); win.Dispose (); Application.Shutdown (); + + } + + /// + protected override void Dispose (bool disposing) + { + base.Dispose (disposing); + _imageView.Dispose (); } private void OpenImage (object sender, HandledEventArgs e) From db0fc41d5426861f3d815cb78db3a3f99d6f80e8 Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 29 Sep 2024 10:37:14 +0100 Subject: [PATCH 30/62] Determine whether sixel is supported by issuing a Device Attributes Request --- Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs | 3 +++ .../ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs | 4 ++++ Terminal.Gui/ConsoleDrivers/NetDriver.cs | 10 +++++++++- UICatalog/Scenarios/Images.cs | 14 +++++++++++++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs b/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs index 9f2a454654..617c1be61b 100644 --- a/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs @@ -484,6 +484,9 @@ public virtual void Move (int col, int row) /// Gets whether the supports TrueColor output. public virtual bool SupportsTrueColor => true; + // TODO: make not static TODO: gets set in mouse logic in net driver :/ + public static bool SupportsSixel { get; set; } + // TODO: This makes ConsoleDriver dependent on Application, which is not ideal. This should be moved to Application. // BUGBUG: Application.Force16Colors should be bool? so if SupportsTrueColor and Application.Force16Colors == false, this doesn't override /// diff --git a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs index 1eb63e34aa..d4b6fc47b7 100644 --- a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs +++ b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs @@ -47,6 +47,10 @@ public enum ClearScreenOptions /// public const string CSI = "\u001B["; + + public const string CSI_Device_Attributes_Request = CSI + "c"; + public const string CSI_Device_Attributes_Request_Terminator = "c"; + /// /// ESC [ ? 1047 h - Activate xterm alternative buffer (no backscroll) /// diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index 6d49d62793..d72017a47a 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -632,6 +632,9 @@ private void HandleRequestResponseEvent (string c1Control, string code, string [ break; } + break; + case EscSeqUtils.CSI_Device_Attributes_Request_Terminator: + ConsoleDriver.SupportsSixel = values.Any (v => v == "4"); break; default: EnqueueRequestResponseEvent (c1Control, code, values, terminating); @@ -1135,9 +1138,13 @@ internal override MainLoop Init () _mainLoopDriver = new NetMainLoop (this); _mainLoopDriver.ProcessInput = ProcessInput; + _mainLoopDriver._netEvents.EscSeqRequests.Add ("c"); + // Determine if sixel is supported + Console.Out.Write (EscSeqUtils.CSI_Device_Attributes_Request); + return new MainLoop (_mainLoopDriver); } - + private void ProcessInput (InputResult inputEvent) { switch (inputEvent.EventType) @@ -1338,6 +1345,7 @@ private bool SetCursorPosition (int col, int row) } private CursorVisibility? _cachedCursorVisibility; + private static bool _supportsSixel; public override void UpdateCursor () { diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index cdab42e3c0..0c79df0c42 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -53,6 +53,18 @@ public override void Main () }; win.Add (cbSupportsTrueColor); + var cbSupportsSixel = new CheckBox + { + X = Pos.Right (lblDriverName) + 2, + Y = 1, + CheckedState = ConsoleDriver.SupportsSixel + ? CheckState.Checked : CheckState.UnChecked, + CanFocus = false, + Enabled = false, + Text = "Supports Sixel" + }; + win.Add (cbSupportsSixel); + var cbUseTrueColor = new CheckBox { X = Pos.Right (cbSupportsTrueColor) + 2, @@ -69,7 +81,7 @@ public override void Main () var tv = new TabView { - Y = Pos.Bottom (lblDriverName), Width = Dim.Fill (), Height = Dim.Fill () + Y = Pos.Bottom (cbSupportsSixel), Width = Dim.Fill (), Height = Dim.Fill () }; tv.AddTab (tabBasic, true); From 08aa9925b568495f5dd547ec9bf84d51c76a2d4f Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 30 Sep 2024 19:34:43 +0100 Subject: [PATCH 31/62] Switch to existing consts --- Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs | 4 ---- Terminal.Gui/ConsoleDrivers/NetDriver.cs | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs index d4b6fc47b7..1eb63e34aa 100644 --- a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs +++ b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs @@ -47,10 +47,6 @@ public enum ClearScreenOptions /// public const string CSI = "\u001B["; - - public const string CSI_Device_Attributes_Request = CSI + "c"; - public const string CSI_Device_Attributes_Request_Terminator = "c"; - /// /// ESC [ ? 1047 h - Activate xterm alternative buffer (no backscroll) /// diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index d72017a47a..e2a9ff9d80 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -633,7 +633,7 @@ private void HandleRequestResponseEvent (string c1Control, string code, string [ } break; - case EscSeqUtils.CSI_Device_Attributes_Request_Terminator: + case EscSeqUtils.CSI_ReportDeviceAttributes_Terminator: ConsoleDriver.SupportsSixel = values.Any (v => v == "4"); break; default: @@ -1140,7 +1140,7 @@ internal override MainLoop Init () _mainLoopDriver._netEvents.EscSeqRequests.Add ("c"); // Determine if sixel is supported - Console.Out.Write (EscSeqUtils.CSI_Device_Attributes_Request); + Console.Out.Write (EscSeqUtils.CSI_SendDeviceAttributes); return new MainLoop (_mainLoopDriver); } From b67c662b2089a67425060eb66af75839fab86f2d Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 30 Sep 2024 21:04:29 +0100 Subject: [PATCH 32/62] WIP: trying to get fully transparent alpha to not render --- Terminal.Gui/Drawing/SixelEncoder.cs | 13 +++ UICatalog/Scenarios/Images.cs | 155 +++++++++++++++++++++++-- UnitTests/Drawing/SixelEncoderTests.cs | 82 +++++++++++++ 3 files changed, 241 insertions(+), 9 deletions(-) diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index 5929752dfe..77f623d273 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -113,13 +113,24 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi for (int x = 0; x < width; ++x) { Array.Clear (code, 0, usedColorIdx.Count); + bool anyNonTransparentPixel = false; // Track if any non-transparent pixels are found in this column // Process each row in the 6-pixel high band for (int row = 0; row < bandHeight; ++row) { var color = pixels [x, startY + row]; + int colorIndex = Quantizer.GetNearestColor (color); + if (color.A == 0) // Skip fully transparent pixels + { + continue; + } + else + { + anyNonTransparentPixel = true; + } + if (slots [colorIndex] == -1) { targets.Add (new List ()); @@ -135,6 +146,8 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi code [slots [colorIndex]] |= (byte)(1 << row); // Accumulate SIXEL data } + // TODO: Handle fully empty rows better + // Handle transitions between columns for (int j = 0; j < usedColorIdx.Count; ++j) { diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 0c79df0c42..817e4cfa5a 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -22,11 +22,12 @@ public class Images : Scenario private ImageView _imageView; private Point _screenLocationForSixel; private string _encodedSixelData; + private Window _win; public override void Main () { Application.Init (); - var win = new Window { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; + _win = new Window { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; bool canTrueColor = Application.Driver?.SupportsTrueColor ?? false; @@ -41,7 +42,7 @@ public override void Main () }; var lblDriverName = new Label { X = 0, Y = 0, Text = $"Driver is {Application.Driver?.GetType ().Name}" }; - win.Add (lblDriverName); + _win.Add (lblDriverName); var cbSupportsTrueColor = new CheckBox { @@ -51,7 +52,7 @@ public override void Main () CanFocus = false, Text = "supports true color " }; - win.Add (cbSupportsTrueColor); + _win.Add (cbSupportsTrueColor); var cbSupportsSixel = new CheckBox { @@ -63,7 +64,7 @@ public override void Main () Enabled = false, Text = "Supports Sixel" }; - win.Add (cbSupportsSixel); + _win.Add (cbSupportsSixel); var cbUseTrueColor = new CheckBox { @@ -74,10 +75,15 @@ public override void Main () Text = "Use true color" }; cbUseTrueColor.CheckedStateChanging += (_, evt) => Application.Force16Colors = evt.NewValue == CheckState.UnChecked; - win.Add (cbUseTrueColor); + _win.Add (cbUseTrueColor); var btnOpenImage = new Button { X = Pos.Right (cbUseTrueColor) + 2, Y = 0, Text = "Open Image" }; - win.Add (btnOpenImage); + _win.Add (btnOpenImage); + + var btnStartFire = new Button { X = Pos.Right (cbUseTrueColor) + 2, Y = 1, Text = "Start Fire" }; + _win.Add (btnStartFire); + + btnStartFire.Accept += BtnStartFireOnAccept; var tv = new TabView { @@ -92,11 +98,38 @@ public override void Main () btnOpenImage.Accept += OpenImage; - win.Add (tv); - Application.Run (win); - win.Dispose (); + _win.Add (tv); + Application.Run (_win); + _win.Dispose (); Application.Shutdown (); + } + + private void BtnStartFireOnAccept (object sender, HandledEventArgs e) + { + var fire = new DoomFire (_win.Frame.Width, _win.Frame.Height); + var encoder = new SixelEncoder (); + encoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (fire.Palette); + + Application.AddTimeout ( + TimeSpan.FromMilliseconds (500), + () => + { + fire.AdvanceFrame (); + + var bmp = fire.GetFirePixels (); + // TODO: Static way of doing this, suboptimal + Application.Sixel.Clear (); + Application.Sixel.Add (new SixelToRender + { + SixelData = encoder.EncodeSixel (bmp), + ScreenPosition = new Point (0,0) + }); + + _win.SetNeedsDisplay(); + + return true; + }); } /// @@ -150,6 +183,7 @@ private void OpenImage (object sender, HandledEventArgs e) return; } + _imageView.SetImage (img); Application.Refresh (); } @@ -465,6 +499,19 @@ public override void OnDrawContent (Rectangle bounds) } } +internal class ConstPalette : IPaletteBuilder +{ + private readonly List _palette; + + public ConstPalette (Color [] palette) { _palette = palette.ToList (); } + + /// + public List BuildPalette (List colors, int maxColors) + { + return _palette; + } +} + public abstract class LabColorDistance : IColorDistance { // Reference white point for D65 illuminant (can be moved to constants) @@ -677,3 +724,93 @@ private int Volume (List cube) return (maxR - minR) * (maxG - minG) * (maxB - minB); } } + + +public class DoomFire +{ + private int _width; + private int _height; + private Color [,] _firePixels; + private static Color [] _palette; + public Color [] Palette => _palette; + + public DoomFire (int width, int height) + { + _width = width; + _height = height; + _firePixels = new Color [width, height]; + InitializePalette (); + InitializeFire (); + } + + private void InitializePalette () + { + // Initialize a basic fire palette. You can modify these colors as needed. + _palette = new Color [37]; // Using 37 colors as per the original Doom fire palette scale. + + // First color is transparent black + _palette [0] = new Color (0, 0, 0, 0); // Transparent black (ARGB) + + // The rest of the palette is fire colors + for (int i = 1; i < 37; i++) + { + byte r = (byte)Math.Min (255, i * 7); + byte g = (byte)Math.Min (255, i * 5); + byte b = (byte)Math.Min (255, i * 2); + _palette [i] = new Color (r, g, b); // Full opacity + } + } + + public void InitializeFire () + { + // Set the bottom row to full intensity (simulate the base of the fire). + for (int x = 0; x < _width; x++) + { + _firePixels [x, _height - 1] = _palette [36]; // Max intensity fire. + } + + // Set the rest of the pixels to black (transparent). + for (int y = 0; y < _height - 1; y++) + { + for (int x = 0; x < _width; x++) + { + _firePixels [x, y] = _palette [0]; // Transparent black + } + } + } + + public void AdvanceFrame () + { + // Process every pixel except the bottom row + for (int x = 0; x < _width; x++) + { + for (int y = 1; y < _height; y++) // Skip the last row (which is always max intensity) + { + int srcX = x; + int srcY = y; + int dstY = y - 1; + + // Spread fire upwards with randomness + int decay = new Random ().Next (0, 3); + int dstX = Math.Max (0, srcX - decay); + + // Get the fire color from below and reduce its intensity + Color srcColor = _firePixels [srcX, srcY]; + int intensity = Array.IndexOf (_palette, srcColor) - decay; + + if (intensity < 0) + { + intensity = 0; + } + + _firePixels [dstX, dstY] = _palette [intensity]; + } + } + } + + public Color [,] GetFirePixels () + { + return _firePixels; + } +} + diff --git a/UnitTests/Drawing/SixelEncoderTests.cs b/UnitTests/Drawing/SixelEncoderTests.cs index 3e84f5b8ca..100eed03d4 100644 --- a/UnitTests/Drawing/SixelEncoderTests.cs +++ b/UnitTests/Drawing/SixelEncoderTests.cs @@ -149,4 +149,86 @@ public void EncodeSixel_12x12GridPattern3x3_ReturnsExpectedSixel () // Compare the generated SIXEL string with the expected one Assert.Equal (expected, result); } + + [Fact] + public void EncodeSixel_Transparent12x12_ReturnsExpectedSixel () + { + string expected = "\u001bP" // Start sixel sequence + + "0;0;0" // Defaults for aspect ratio and grid size + + "q" // Signals beginning of sixel image data + + "\"1;1;12;12" // no scaling factors (1x1) and filling 12x12 pixel area + + "#0;2;0;0;0" // Black transparent (TODO: Shouldn't really be output this if it is transparent) + // Since all pixels are transparent, the data should just be filled with '?' + + "#0!12?$-" // Fills the transparent line with byte 0 which maps to '?' + + "#0!12?$" // Second band, same fully transparent pixels + + "\u001b\\"; // End sixel sequence + + // Arrange: Create a 12x12 bitmap filled with fully transparent pixels + Color [,] pixels = new Color [12, 12]; + + for (var x = 0; x < 12; x++) + { + for (var y = 0; y < 12; y++) + { + pixels [x, y] = new (0, 0, 0, 0); // Fully transparent + } + } + + // Act: Encode the image + var encoder = new SixelEncoder (); + string result = encoder.EncodeSixel (pixels); + + // Assert: Expect the result to be fully transparent encoded output + Assert.Equal (expected, result); + } + [Fact] + public void EncodeSixel_VerticalMix_TransparentAndColor_ReturnsExpectedSixel () + { + string expected = "\u001bP" // Start sixel sequence + + "0;0;0" // Defaults for aspect ratio and grid size + + "q" // Signals beginning of sixel image data + + "\"1;1;12;12" // No scaling factors (1x1) and filling 12x12 pixel area + /* + * Define the color palette: + * We'll use one color (Red) for the colored pixels. + */ + + "#0;2;100;0;0" // Red color definition (index 0: RGB 100,0,0) + + "#1;2;0;0;0" // Black transparent (TODO: Shouldn't really be output this if it is transparent) + /* + * Start of the Pixel data + * We have alternating transparent (0) and colored (red) pixels in a vertical band. + * The pattern for each sixel byte is 101010, which in binary (+63) converts to ASCII character 'T'. + * Since we have 12 pixels horizontally, we'll see this pattern repeat across the row so we see + * the 'sequence repeat' 12 times i.e. !12 (do the next letter 'T' 12 times). + */ + + "#0!12T$-" // First band of alternating red and transparent pixels + + "#0!12T$" // Second band, same alternating red and transparent pixels + + "\u001b\\"; // End sixel sequence + + // Arrange: Create a 12x12 bitmap with alternating transparent and red pixels in a vertical band + Color [,] pixels = new Color [12, 12]; + + for (var x = 0; x < 12; x++) + { + for (var y = 0; y < 12; y++) + { + // For simplicity, we'll make every other row transparent + if (y % 2 == 0) + { + pixels [x, y] = new (255, 0, 0); // Red pixel + } + else + { + pixels [x, y] = new (0, 0, 0, 0); // Transparent pixel + } + } + } + + // Act: Encode the image + var encoder = new SixelEncoder (); + string result = encoder.EncodeSixel (pixels); + + // Assert: Expect the result to match the expected sixel output + Assert.Equal (expected, result); + } } From c240cb165e22a033a7ddf835784649e5abaebd68 Mon Sep 17 00:00:00 2001 From: tznind Date: Tue, 1 Oct 2024 20:44:34 +0100 Subject: [PATCH 33/62] Fix for when we want alpha pixels --- Terminal.Gui/Drawing/SixelEncoder.cs | 43 ++++++++++++++++++++++++++-- UICatalog/Scenarios/Images.cs | 41 +++++++++++++++++--------- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index 77f623d273..6681957368 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -53,7 +53,8 @@ public string EncodeSixel (Color [,] pixels) const string start = "\u001bP"; // Start sixel sequence - const string defaultRatios = "0;0;0"; // Defaults for aspect ratio and grid size + + string defaultRatios = this.AnyHasAlphaOfZero(pixels) ? "0;1;0": "0;0;0"; // Defaults for aspect ratio and grid size const string completeStartSequence = "q"; // Signals beginning of sixel image data const string noScaling = "\"1;1;"; // no scaling factors (1x1); @@ -146,7 +147,26 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi code [slots [colorIndex]] |= (byte)(1 << row); // Accumulate SIXEL data } - // TODO: Handle fully empty rows better + /* + // If no non-transparent pixels are found in the entire column, it's fully transparent + if (!anyNonTransparentPixel) + { + // Emit fully transparent pixel data: #0!?$ + result.Append ($"#0!{width}?"); + + // Add the line terminator: use "$-" if it's not the last line, "$" if it's the last line + if (x < width - 1) + { + result.Append ("$-"); + } + else + { + result.Append ("$"); + } + + // Skip to the next column as we have already handled transparency + continue; + }*/ // Handle transitions between columns for (int j = 0; j < usedColorIdx.Count; ++j) @@ -221,4 +241,23 @@ private string GetFillArea (Color [,] pixels) return $"{widthInChars};{heightInChars}"; } + private bool AnyHasAlphaOfZero (Color [,] pixels) + { + int width = pixels.GetLength (0); + int height = pixels.GetLength (1); + + // Loop through each pixel in the 2D array + for (int x = 0; x < width; x++) + { + for (int y = 0; y < height; y++) + { + // Check if the alpha component (A) is 0 + if (pixels [x, y].A == 0) + { + return true; // Found a pixel with A of 0 + } + } + } + return false; // No pixel with A of 0 was found + } } \ No newline at end of file diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 817e4cfa5a..f873f75722 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -23,6 +23,8 @@ public class Images : Scenario private Point _screenLocationForSixel; private string _encodedSixelData; private Window _win; + private NumericUpDown _pxY; + private NumericUpDown _pxX; public override void Main () { @@ -106,15 +108,22 @@ public override void Main () private void BtnStartFireOnAccept (object sender, HandledEventArgs e) { - var fire = new DoomFire (_win.Frame.Width, _win.Frame.Height); + + var fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); var encoder = new SixelEncoder (); encoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (fire.Palette); + int counter = 0; Application.AddTimeout ( - TimeSpan.FromMilliseconds (500), + TimeSpan.FromMilliseconds (30), () => { fire.AdvanceFrame (); + counter++; + if (counter % 5 != 0) + { + return true; + } var bmp = fire.GetFirePixels (); @@ -228,11 +237,10 @@ private void BuildSixelTab (Tab tabSixel) X = Pos.Right (sixelView), Text = "Pixels per Col:" }; - - var pxX = new NumericUpDown + _pxX = new NumericUpDown { X = Pos.Right (lblPxX), - Value = 12 + Value = 10 }; var lblPxY = new Label @@ -241,18 +249,17 @@ private void BuildSixelTab (Tab tabSixel) Y = 1, Text = "Pixels per Row:" }; - - var pxY = new NumericUpDown + _pxY = new NumericUpDown { X = Pos.Right (lblPxY), Y = 1, - Value = 6 + Value = 20 }; tabSixel.View.Add (lblPxX); - tabSixel.View.Add (pxX); + tabSixel.View.Add (_pxX); tabSixel.View.Add (lblPxY); - tabSixel.View.Add (pxY); + tabSixel.View.Add (_pxY); sixelView.DrawContent += SixelViewOnDrawContent; @@ -270,8 +277,8 @@ private void BuildSixelTab (Tab tabSixel) _encodedSixelData = GenerateSixelData( _imageView.FullResImage, sixelView.Frame.Size, - pxX.Value, - pxY.Value); + _pxX.Value, + _pxY.Value); // TODO: Static way of doing this, suboptimal Application.Sixel.Add (new SixelToRender @@ -733,6 +740,7 @@ public class DoomFire private Color [,] _firePixels; private static Color [] _palette; public Color [] Palette => _palette; + private Random _random = new Random (); public DoomFire (int width, int height) { @@ -791,8 +799,13 @@ public void AdvanceFrame () int dstY = y - 1; // Spread fire upwards with randomness - int decay = new Random ().Next (0, 3); - int dstX = Math.Max (0, srcX - decay); + int decay = _random.Next (0, 2); + int dstX = srcX + _random.Next (-1, 2); + + if (dstX < 0 || dstX >= _width) // Prevent out of bounds + { + dstX = srcX; + } // Get the fire color from below and reduce its intensity Color srcColor = _firePixels [srcX, srcY]; From 5e356c2b249796a39d8c67d7746d5486a9c66edf Mon Sep 17 00:00:00 2001 From: tznind Date: Thu, 3 Oct 2024 19:04:59 +0100 Subject: [PATCH 34/62] WindowsDriver prototype sixel support --- Terminal.Gui/ConsoleDrivers/WindowsDriver.cs | 18 ++++++++++++++++++ Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 10 +++++++++- UICatalog/Scenarios/Images.cs | 5 ++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs index 01a8fcd8e1..921b96272f 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs @@ -37,6 +37,7 @@ internal class WindowsConsole private CursorVisibility? _currentCursorVisibility; private CursorVisibility? _pendingCursorVisibility; private readonly StringBuilder _stringBuilder = new (256 * 1024); + private string _lastWrite = string.Empty; public WindowsConsole () { @@ -116,7 +117,24 @@ public bool WriteToConsole (Size size, ExtendedCharInfo [] charInfoBuffer, Coord var s = _stringBuilder.ToString (); + // TODO: requires extensive testing if we go down this route + // If console output has changed + if (s != _lastWrite) + { + // supply console with the new content + result = WriteConsole (_screenBuffer, s, (uint)s.Length, out uint _, nint.Zero); + } + result = WriteConsole (_screenBuffer, s, (uint)s.Length, out uint _, nint.Zero); + + _lastWrite = s; + + foreach (var sixel in Application.Sixel) + { + SetCursorPosition (new Coord ((short)sixel.ScreenPosition.X, (short)sixel.ScreenPosition.Y)); + WriteConsole (_screenBuffer, sixel.SixelData, (uint)sixel.SixelData.Length, out uint _, nint.Zero); + + } } if (!result) diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index 51071b2a57..d6aa95d748 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -1,4 +1,4 @@ - +using System.Collections.Concurrent; namespace Terminal.Gui; @@ -30,6 +30,8 @@ public class ColorQuantizer /// public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (),5) ; + private readonly ConcurrentDictionary _nearestColorCache = new (); + public void BuildPalette (Color [,] pixels) { List allColors = new List (); @@ -49,6 +51,11 @@ public void BuildPalette (Color [,] pixels) public int GetNearestColor (Color toTranslate) { + if (_nearestColorCache.TryGetValue (toTranslate, out var cachedAnswer)) + { + return cachedAnswer; + } + // Simple nearest color matching based on DistanceAlgorithm double minDistance = double.MaxValue; int nearestIndex = 0; @@ -65,6 +72,7 @@ public int GetNearestColor (Color toTranslate) } } + _nearestColorCache.TryAdd (toTranslate, nearestIndex); return nearestIndex; } } \ No newline at end of file diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index f873f75722..3b275cb567 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -120,7 +120,10 @@ private void BtnStartFireOnAccept (object sender, HandledEventArgs e) { fire.AdvanceFrame (); counter++; - if (counter % 5 != 0) + + // Control frame rate by adjusting this + // Lower number means more FPS + if (counter % 2 != 0) { return true; } From d60b1fa347c96afeb0444816f08f58a65e41f9a7 Mon Sep 17 00:00:00 2001 From: tznind Date: Thu, 3 Oct 2024 19:08:34 +0100 Subject: [PATCH 35/62] Remove double paint! --- Terminal.Gui/ConsoleDrivers/WindowsDriver.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs index 921b96272f..c476ae95e0 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs @@ -125,8 +125,6 @@ public bool WriteToConsole (Size size, ExtendedCharInfo [] charInfoBuffer, Coord result = WriteConsole (_screenBuffer, s, (uint)s.Length, out uint _, nint.Zero); } - result = WriteConsole (_screenBuffer, s, (uint)s.Length, out uint _, nint.Zero); - _lastWrite = s; foreach (var sixel in Application.Sixel) From 23cd8889a526f20f7e4a6e96e87f6d9712a085e8 Mon Sep 17 00:00:00 2001 From: tznind Date: Thu, 3 Oct 2024 19:47:01 +0100 Subject: [PATCH 36/62] Do not output sixel if driver does not support it Allow overriding driver sixel support in Images scenario --- Terminal.Gui/ConsoleDrivers/NetDriver.cs | 11 +++++++---- Terminal.Gui/ConsoleDrivers/WindowsDriver.cs | 9 ++++++--- UICatalog/Scenarios/Images.cs | 7 +++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index e2a9ff9d80..b5729406ce 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -1024,12 +1024,15 @@ public override void UpdateScreen () Console.Write (output); } - foreach (var s in Application.Sixel) + if (ConsoleDriver.SupportsSixel) { - if (!string.IsNullOrWhiteSpace (s.SixelData)) + foreach (var s in Application.Sixel) { - SetCursorPosition (s.ScreenPosition.X, s.ScreenPosition.Y); - Console.Write (s.SixelData); + if (!string.IsNullOrWhiteSpace (s.SixelData)) + { + SetCursorPosition (s.ScreenPosition.X, s.ScreenPosition.Y); + Console.Write (s.SixelData); + } } } } diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs index c476ae95e0..aff9a57c84 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs @@ -127,11 +127,14 @@ public bool WriteToConsole (Size size, ExtendedCharInfo [] charInfoBuffer, Coord _lastWrite = s; - foreach (var sixel in Application.Sixel) + if (ConsoleDriver.SupportsSixel) { - SetCursorPosition (new Coord ((short)sixel.ScreenPosition.X, (short)sixel.ScreenPosition.Y)); - WriteConsole (_screenBuffer, sixel.SixelData, (uint)sixel.SixelData.Length, out uint _, nint.Zero); + foreach (var sixel in Application.Sixel) + { + SetCursorPosition (new Coord ((short)sixel.ScreenPosition.X, (short)sixel.ScreenPosition.Y)); + WriteConsole (_screenBuffer, sixel.SixelData, (uint)sixel.SixelData.Length, out uint _, nint.Zero); + } } } diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 3b275cb567..16c350c24b 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -62,10 +62,13 @@ public override void Main () Y = 1, CheckedState = ConsoleDriver.SupportsSixel ? CheckState.Checked : CheckState.UnChecked, - CanFocus = false, - Enabled = false, Text = "Supports Sixel" }; + + cbSupportsSixel.CheckedStateChanging += (s, e) => + { + ConsoleDriver.SupportsSixel = e.NewValue == CheckState.Checked; + }; _win.Add (cbSupportsSixel); var cbUseTrueColor = new CheckBox From 14a5fa7301ef2c62b66a53d4dbec780d6e56e1db Mon Sep 17 00:00:00 2001 From: tznind Date: Fri, 4 Oct 2024 02:27:10 +0100 Subject: [PATCH 37/62] Improve usability of Images scenario --- UICatalog/Scenarios/Images.cs | 255 +++++++++++++++++++++------------- 1 file changed, 158 insertions(+), 97 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 16c350c24b..fc690c3c85 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -23,13 +23,39 @@ public class Images : Scenario private Point _screenLocationForSixel; private string _encodedSixelData; private Window _win; + + /// + /// Number of sixel pixels per row of characters in the console. + /// private NumericUpDown _pxY; + + /// + /// Number of sixel pixels per column of characters in the console + /// private NumericUpDown _pxX; + /// + /// View shown in sixel tab if sixel is supported + /// + private View _sixelSupported; + + /// + /// View shown in sixel tab if sixel is not supported + /// + private View _sixelNotSupported; + + private Tab _tabSixel; + private TabView _tabView; + + /// + /// The view into which the currently opened sixel image is bounded + /// + private View _sixelView; + public override void Main () { Application.Init (); - _win = new Window { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; + _win = new() { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; bool canTrueColor = Application.Driver?.SupportsTrueColor ?? false; @@ -38,7 +64,7 @@ public override void Main () DisplayText = "Basic" }; - var tabSixel = new Tab + _tabSixel = new() { DisplayText = "Sixel" }; @@ -61,14 +87,17 @@ public override void Main () X = Pos.Right (lblDriverName) + 2, Y = 1, CheckedState = ConsoleDriver.SupportsSixel - ? CheckState.Checked : CheckState.UnChecked, + ? CheckState.Checked + : CheckState.UnChecked, Text = "Supports Sixel" }; cbSupportsSixel.CheckedStateChanging += (s, e) => { ConsoleDriver.SupportsSixel = e.NewValue == CheckState.Checked; + SetupSixelSupported (e.NewValue == CheckState.Checked); }; + _win.Add (cbSupportsSixel); var cbUseTrueColor = new CheckBox @@ -85,38 +114,41 @@ public override void Main () var btnOpenImage = new Button { X = Pos.Right (cbUseTrueColor) + 2, Y = 0, Text = "Open Image" }; _win.Add (btnOpenImage); - var btnStartFire = new Button { X = Pos.Right (cbUseTrueColor) + 2, Y = 1, Text = "Start Fire" }; - _win.Add (btnStartFire); - - btnStartFire.Accept += BtnStartFireOnAccept; - - var tv = new TabView + _tabView = new() { - Y = Pos.Bottom (cbSupportsSixel), Width = Dim.Fill (), Height = Dim.Fill () + Y = Pos.Bottom (btnOpenImage), Width = Dim.Fill (), Height = Dim.Fill () }; - tv.AddTab (tabBasic, true); - tv.AddTab (tabSixel, false); + _tabView.AddTab (tabBasic, true); + _tabView.AddTab (_tabSixel, false); BuildBasicTab (tabBasic); - BuildSixelTab (tabSixel); + BuildSixelTab (); + + SetupSixelSupported (cbSupportsSixel.CheckedState == CheckState.Checked); btnOpenImage.Accept += OpenImage; - _win.Add (tv); + _win.Add (_tabView); Application.Run (_win); _win.Dispose (); Application.Shutdown (); } + private void SetupSixelSupported (bool isSupported) + { + _tabSixel.View = isSupported ? _sixelSupported : _sixelNotSupported; + _tabView.SetNeedsDisplay (); + } + private void BtnStartFireOnAccept (object sender, HandledEventArgs e) { - var fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); var encoder = new SixelEncoder (); encoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (fire.Palette); - int counter = 0; + var counter = 0; + Application.AddTimeout ( TimeSpan.FromMilliseconds (30), () => @@ -131,27 +163,31 @@ private void BtnStartFireOnAccept (object sender, HandledEventArgs e) return true; } - var bmp = fire.GetFirePixels (); + Color [,] bmp = fire.GetFirePixels (); // TODO: Static way of doing this, suboptimal Application.Sixel.Clear (); - Application.Sixel.Add (new SixelToRender - { - SixelData = encoder.EncodeSixel (bmp), - ScreenPosition = new Point (0,0) - }); - _win.SetNeedsDisplay(); + Application.Sixel.Add ( + new() + { + SixelData = encoder.EncodeSixel (bmp), + ScreenPosition = new (0, 0) + }); + + _win.SetNeedsDisplay (); return true; }); } - /// + /// protected override void Dispose (bool disposing) { base.Dispose (disposing); _imageView.Dispose (); + _sixelNotSupported.Dispose (); + _sixelSupported.Dispose (); } private void OpenImage (object sender, HandledEventArgs e) @@ -198,103 +234,138 @@ private void OpenImage (object sender, HandledEventArgs e) return; } - _imageView.SetImage (img); Application.Refresh (); } private void BuildBasicTab (Tab tabBasic) { - _imageView = new() + _imageView = new () { Width = Dim.Fill (), Height = Dim.Fill (), CanFocus = true - }; tabBasic.View = _imageView; } - private void BuildSixelTab (Tab tabSixel) + private void BuildSixelTab () { - tabSixel.View = new() + _sixelSupported = new() { Width = Dim.Fill (), Height = Dim.Fill (), CanFocus = true }; - var btnSixel = new Button { X = 0, Y = 0, Text = "Output Sixel", Width = Dim.Auto () }; - tabSixel.View.Add (btnSixel); + _sixelNotSupported = new() + { + Width = Dim.Fill (), + Height = Dim.Fill (), + CanFocus = true + }; + + _sixelNotSupported.Add ( + new Label + { + Width = Dim.Fill (), + Height = Dim.Fill (), + TextAlignment = Alignment.Center, + Text = "Your driver does not support Sixel image format", + VerticalTextAlignment = Alignment.Center + }); - var sixelView = new View + _sixelView = new() { - Y = Pos.Bottom (btnSixel), Width = Dim.Percent (50), Height = Dim.Fill (), BorderStyle = LineStyle.Dotted }; - tabSixel.View.Add (sixelView); + _sixelSupported.Add (_sixelView); + + var btnSixel = new Button + { + X = Pos.Right (_sixelView), + Y = 0, + Text = "Output Sixel", Width = Dim.Auto () + }; + btnSixel.Accept += OutputSixelButtonClick; + _sixelSupported.Add (btnSixel); + + var btnStartFire = new Button + { + X = Pos.Right (_sixelView), + Y = Pos.Bottom (btnSixel), + Text = "Start Fire" + }; + btnStartFire.Accept += BtnStartFireOnAccept; + _sixelSupported.Add (btnStartFire); + var lblPxX = new Label { - X = Pos.Right (sixelView), + X = Pos.Right (_sixelView), + Y = Pos.Bottom (btnStartFire) + 1, Text = "Pixels per Col:" }; - _pxX = new NumericUpDown + + _pxX = new() { X = Pos.Right (lblPxX), + Y = Pos.Bottom (btnStartFire) + 1, Value = 10 }; var lblPxY = new Label { X = lblPxX.X, - Y = 1, + Y = Pos.Bottom (_pxX), Text = "Pixels per Row:" }; - _pxY = new NumericUpDown + + _pxY = new() { X = Pos.Right (lblPxY), - Y = 1, + Y = Pos.Bottom (_pxX), Value = 20 }; - tabSixel.View.Add (lblPxX); - tabSixel.View.Add (_pxX); - tabSixel.View.Add (lblPxY); - tabSixel.View.Add (_pxY); + _sixelSupported.Add (lblPxX); + _sixelSupported.Add (_pxX); + _sixelSupported.Add (lblPxY); + _sixelSupported.Add (_pxY); - sixelView.DrawContent += SixelViewOnDrawContent; - - - btnSixel.Accept += (s, e) => - { + _sixelView.DrawContent += SixelViewOnDrawContent; + } - if (_imageView.FullResImage == null) - { - return; - } + private void OutputSixelButtonClick (object sender, HandledEventArgs e) + { + if (_imageView.FullResImage == null) + { + MessageBox.Query ("No Image Loaded", "You must first open an image. Use the 'Open Image' button above.", "Ok"); + return; + } + _screenLocationForSixel = _sixelView.FrameToScreen ().Location; - _screenLocationForSixel = sixelView.FrameToScreen ().Location; - _encodedSixelData = GenerateSixelData( - _imageView.FullResImage, - sixelView.Frame.Size, - _pxX.Value, - _pxY.Value); + _encodedSixelData = GenerateSixelData ( + _imageView.FullResImage, + _sixelView.Frame.Size, + _pxX.Value, + _pxY.Value); - // TODO: Static way of doing this, suboptimal - Application.Sixel.Add (new SixelToRender + // TODO: Static way of doing this, suboptimal + Application.Sixel.Add ( + new() { SixelData = _encodedSixelData, ScreenPosition = _screenLocationForSixel }); - }; } - void SixelViewOnDrawContent (object sender, DrawEventArgs e) + + private void SixelViewOnDrawContent (object sender, DrawEventArgs e) { if (!string.IsNullOrWhiteSpace (_encodedSixelData)) { @@ -308,12 +379,12 @@ void SixelViewOnDrawContent (object sender, DrawEventArgs e) } } - public string GenerateSixelData( - Image fullResImage, - Size maxSize, - int pixelsPerCellX, - int pixelsPerCellY - ) + public string GenerateSixelData ( + Image fullResImage, + Size maxSize, + int pixelsPerCellX, + int pixelsPerCellY + ) { var encoder = new SixelEncoder (); @@ -439,8 +510,6 @@ internal void SetImage (Image image) FullResImage = image; SetNeedsDisplay (); } - - } public class PaletteView : View @@ -514,15 +583,12 @@ public override void OnDrawContent (Rectangle bounds) internal class ConstPalette : IPaletteBuilder { - private readonly List _palette; + private readonly List _palette; public ConstPalette (Color [] palette) { _palette = palette.ToList (); } - /// - public List BuildPalette (List colors, int maxColors) - { - return _palette; - } + /// + public List BuildPalette (List colors, int maxColors) { return _palette; } } public abstract class LabColorDistance : IColorDistance @@ -738,15 +804,14 @@ private int Volume (List cube) } } - public class DoomFire { - private int _width; - private int _height; - private Color [,] _firePixels; + private readonly int _width; + private readonly int _height; + private readonly Color [,] _firePixels; private static Color [] _palette; public Color [] Palette => _palette; - private Random _random = new Random (); + private readonly Random _random = new (); public DoomFire (int width, int height) { @@ -763,30 +828,30 @@ private void InitializePalette () _palette = new Color [37]; // Using 37 colors as per the original Doom fire palette scale. // First color is transparent black - _palette [0] = new Color (0, 0, 0, 0); // Transparent black (ARGB) + _palette [0] = new (0, 0, 0, 0); // Transparent black (ARGB) // The rest of the palette is fire colors - for (int i = 1; i < 37; i++) + for (var i = 1; i < 37; i++) { - byte r = (byte)Math.Min (255, i * 7); - byte g = (byte)Math.Min (255, i * 5); - byte b = (byte)Math.Min (255, i * 2); - _palette [i] = new Color (r, g, b); // Full opacity + var r = (byte)Math.Min (255, i * 7); + var g = (byte)Math.Min (255, i * 5); + var b = (byte)Math.Min (255, i * 2); + _palette [i] = new (r, g, b); // Full opacity } } public void InitializeFire () { // Set the bottom row to full intensity (simulate the base of the fire). - for (int x = 0; x < _width; x++) + for (var x = 0; x < _width; x++) { _firePixels [x, _height - 1] = _palette [36]; // Max intensity fire. } // Set the rest of the pixels to black (transparent). - for (int y = 0; y < _height - 1; y++) + for (var y = 0; y < _height - 1; y++) { - for (int x = 0; x < _width; x++) + for (var x = 0; x < _width; x++) { _firePixels [x, y] = _palette [0]; // Transparent black } @@ -796,9 +861,9 @@ public void InitializeFire () public void AdvanceFrame () { // Process every pixel except the bottom row - for (int x = 0; x < _width; x++) + for (var x = 0; x < _width; x++) { - for (int y = 1; y < _height; y++) // Skip the last row (which is always max intensity) + for (var y = 1; y < _height; y++) // Skip the last row (which is always max intensity) { int srcX = x; int srcY = y; @@ -827,9 +892,5 @@ public void AdvanceFrame () } } - public Color [,] GetFirePixels () - { - return _firePixels; - } + public Color [,] GetFirePixels () { return _firePixels; } } - From 3566ac2c93b9dc4436d7836ae486bf0dec3a2747 Mon Sep 17 00:00:00 2001 From: tznind Date: Fri, 4 Oct 2024 02:45:05 +0100 Subject: [PATCH 38/62] Make sixel output stop on dispose/close Images scenario --- UICatalog/Scenarios/Images.cs | 79 ++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index fc690c3c85..818e1db0b8 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -52,6 +52,12 @@ public class Images : Scenario /// private View _sixelView; + private DoomFire _fire; + private SixelEncoder _encoder; + private int _fireFrameCounter; + private bool _isDisposed; + private SixelToRender _fireSixel; + public override void Main () { Application.Init (); @@ -143,42 +149,56 @@ private void SetupSixelSupported (bool isSupported) private void BtnStartFireOnAccept (object sender, HandledEventArgs e) { - var fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); - var encoder = new SixelEncoder (); - encoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (fire.Palette); + if (_fire != null) + { + return; + } - var counter = 0; + _fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); + _encoder = new SixelEncoder (); + _encoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette); - Application.AddTimeout ( - TimeSpan.FromMilliseconds (30), - () => - { - fire.AdvanceFrame (); - counter++; + _fireFrameCounter = 0; + + Application.AddTimeout (TimeSpan.FromMilliseconds (30), AdvanceFireTimerCallback); + } + + private void StopFire () + { + + } - // Control frame rate by adjusting this - // Lower number means more FPS - if (counter % 2 != 0) - { - return true; - } + private bool AdvanceFireTimerCallback () + { + _fire.AdvanceFrame (); + _fireFrameCounter++; + + // Control frame rate by adjusting this + // Lower number means more FPS + if (_fireFrameCounter % 2 != 0 || _isDisposed) + { + return !_isDisposed; + } + + Color [,] bmp = _fire.GetFirePixels (); - Color [,] bmp = fire.GetFirePixels (); + // TODO: Static way of doing this, suboptimal + if (_fireSixel != null) + { + Application.Sixel.Remove (_fireSixel); + } - // TODO: Static way of doing this, suboptimal - Application.Sixel.Clear (); + _fireSixel = new () + { + SixelData = _encoder.EncodeSixel (bmp), + ScreenPosition = new (0, 0) + }; - Application.Sixel.Add ( - new() - { - SixelData = encoder.EncodeSixel (bmp), - ScreenPosition = new (0, 0) - }); + Application.Sixel.Add (_fireSixel); - _win.SetNeedsDisplay (); + _win.SetNeedsDisplay (); - return true; - }); + return !_isDisposed; } /// @@ -188,6 +208,9 @@ protected override void Dispose (bool disposing) _imageView.Dispose (); _sixelNotSupported.Dispose (); _sixelSupported.Dispose (); + _isDisposed = true; + + Application.Sixel.Clear (); } private void OpenImage (object sender, HandledEventArgs e) From 9058554bf550d2b9bd13bf8f14686b309da7d505 Mon Sep 17 00:00:00 2001 From: tznind Date: Fri, 4 Oct 2024 02:53:37 +0100 Subject: [PATCH 39/62] Adjust default quantizer --- Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 3 ++- UICatalog/Scenarios/Images.cs | 16 ++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index d6aa95d748..ac51399e29 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -28,7 +28,7 @@ public class ColorQuantizer /// /// Gets or sets the algorithm used to build the . /// - public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (),5) ; + public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (),8) ; private readonly ConcurrentDictionary _nearestColorCache = new (); @@ -46,6 +46,7 @@ public void BuildPalette (Color [,] pixels) } } + _nearestColorCache.Clear (); Palette = PaletteBuildingAlgorithm.BuildPalette (allColors, MaxColors); } diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 818e1db0b8..4df644b3af 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -53,10 +53,10 @@ public class Images : Scenario private View _sixelView; private DoomFire _fire; - private SixelEncoder _encoder; + private SixelEncoder _fireEncoder; + private SixelToRender _fireSixel; private int _fireFrameCounter; private bool _isDisposed; - private SixelToRender _fireSixel; public override void Main () { @@ -155,19 +155,14 @@ private void BtnStartFireOnAccept (object sender, HandledEventArgs e) } _fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); - _encoder = new SixelEncoder (); - _encoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette); + _fireEncoder = new SixelEncoder (); + _fireEncoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette); _fireFrameCounter = 0; Application.AddTimeout (TimeSpan.FromMilliseconds (30), AdvanceFireTimerCallback); } - private void StopFire () - { - - } - private bool AdvanceFireTimerCallback () { _fire.AdvanceFrame (); @@ -190,7 +185,7 @@ private bool AdvanceFireTimerCallback () _fireSixel = new () { - SixelData = _encoder.EncodeSixel (bmp), + SixelData = _fireEncoder.EncodeSixel (bmp), ScreenPosition = new (0, 0) }; @@ -410,6 +405,7 @@ int pixelsPerCellY ) { var encoder = new SixelEncoder (); + // Calculate the target size in pixels based on console units int targetWidthInPixels = maxSize.Width * pixelsPerCellX; From c4c7754715b69c3f8981451a350f79d4249adbba Mon Sep 17 00:00:00 2001 From: tznind Date: Fri, 4 Oct 2024 03:09:36 +0100 Subject: [PATCH 40/62] Add UI components for sixel algorithm --- UICatalog/Scenarios/Images.cs | 64 +++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 4df644b3af..3e8ff158cc 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -57,6 +57,9 @@ public class Images : Scenario private SixelToRender _fireSixel; private int _fireFrameCounter; private bool _isDisposed; + private RadioGroup _rgPaletteBuilder; + private RadioGroup _rgDistanceAlgorithm; + private NumericUpDown _popularityThreshold; public override void Main () { @@ -350,14 +353,75 @@ private void BuildSixelTab () Value = 20 }; + _rgPaletteBuilder = new RadioGroup + { + RadioLabels = new [] + { + "Median Cut", + "Popularity" + }, + X = Pos.Right (_sixelView), + Y = Pos.Bottom (_pxY)+1, + SelectedItem = 0 + }; + + _popularityThreshold = new () + { + X = Pos.Right (_rgPaletteBuilder), + Y = Pos.Top (_rgPaletteBuilder)+1, + Value = 8 + }; + + var lblPopThreshold = new Label () + { + Text = "(threshold)", + X = Pos.Right (_popularityThreshold), + Y = Pos.Top (_popularityThreshold), + }; + + _rgDistanceAlgorithm = new RadioGroup () + { + RadioLabels = new [] + { + "Euclidian", + "CIE76" + }, + X = Pos.Right (_sixelView), + Y = Pos.Bottom (_rgPaletteBuilder)+1, + }; + _sixelSupported.Add (lblPxX); _sixelSupported.Add (_pxX); _sixelSupported.Add (lblPxY); _sixelSupported.Add (_pxY); + _sixelSupported.Add (_rgPaletteBuilder); + _sixelSupported.Add (_rgDistanceAlgorithm); + _sixelSupported.Add (_popularityThreshold); + _sixelSupported.Add (lblPopThreshold); _sixelView.DrawContent += SixelViewOnDrawContent; } + IPaletteBuilder GetPaletteBuilder () + { + switch (_rgPaletteBuilder.SelectedItem) + { + case 0: return new MedianCutPaletteBuilder (GetDistanceAlgorithm ()); + case 1: return new PopularityPaletteWithThreshold (GetDistanceAlgorithm (), _popularityThreshold.Value); + default: throw new ArgumentOutOfRangeException (); + } + } + + IColorDistance GetDistanceAlgorithm () + { + switch (_rgDistanceAlgorithm.SelectedItem) + { + case 0: return new EuclideanColorDistance (); + case 1: return new CIE76ColorDistance (); + default: throw new ArgumentOutOfRangeException (); + } + } + private void OutputSixelButtonClick (object sender, HandledEventArgs e) { if (_imageView.FullResImage == null) From 9d5d853de39d4e029032a04f74f9804d41b1320a Mon Sep 17 00:00:00 2001 From: tznind Date: Fri, 4 Oct 2024 03:10:50 +0100 Subject: [PATCH 41/62] Actually call relevant builders --- UICatalog/Scenarios/Images.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 3e8ff158cc..13025cdbaf 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -469,7 +469,8 @@ int pixelsPerCellY ) { var encoder = new SixelEncoder (); - + encoder.Quantizer.PaletteBuildingAlgorithm = GetPaletteBuilder (); + encoder.Quantizer.DistanceAlgorithm = GetDistanceAlgorithm (); // Calculate the target size in pixels based on console units int targetWidthInPixels = maxSize.Width * pixelsPerCellX; From 36a8cba6e4b264cfb98dd75468f85601950c0c82 Mon Sep 17 00:00:00 2001 From: tznind Date: Fri, 4 Oct 2024 07:49:00 +0100 Subject: [PATCH 42/62] Make updates to the sixel image easier (e.g. changing algorithm) --- UICatalog/Scenarios/Images.cs | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 13025cdbaf..0a47937993 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -60,6 +60,7 @@ public class Images : Scenario private RadioGroup _rgPaletteBuilder; private RadioGroup _rgDistanceAlgorithm; private NumericUpDown _popularityThreshold; + private SixelToRender _sixelImage; public override void Main () { @@ -438,13 +439,25 @@ private void OutputSixelButtonClick (object sender, HandledEventArgs e) _pxX.Value, _pxY.Value); - // TODO: Static way of doing this, suboptimal - Application.Sixel.Add ( - new() - { - SixelData = _encodedSixelData, - ScreenPosition = _screenLocationForSixel - }); + if (_sixelImage == null) + { + _sixelImage = new () + { + SixelData = _encodedSixelData, + ScreenPosition = _screenLocationForSixel + }; + + Application.Sixel.Add (_sixelImage); + } + else + { + _sixelImage.ScreenPosition = _screenLocationForSixel; + _sixelImage.SixelData = _encodedSixelData; + } + + _sixelView.SetNeedsDisplay(); + + } private void SixelViewOnDrawContent (object sender, DrawEventArgs e) From a6b12213de77f78b67ee9de3fafef30c050c344c Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 5 Oct 2024 19:31:48 +0100 Subject: [PATCH 43/62] Tidy up scenario quantizer options --- UICatalog/Scenarios/Images.cs | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 0a47937993..cb35dfd235 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -354,22 +354,30 @@ private void BuildSixelTab () Value = 20 }; + var l1 = new Label () + { + Text = "Palette Building Algorithm", + Width = Dim.Auto (), + X = Pos.Right (_sixelView), + Y = Pos.Bottom (_pxY) + 1, + }; + _rgPaletteBuilder = new RadioGroup { RadioLabels = new [] { + "Popularity", "Median Cut", - "Popularity" }, - X = Pos.Right (_sixelView), - Y = Pos.Bottom (_pxY)+1, - SelectedItem = 0 + X = Pos.Right (_sixelView) + 2, + Y = Pos.Bottom (l1), + SelectedItem = 1 }; _popularityThreshold = new () { - X = Pos.Right (_rgPaletteBuilder), - Y = Pos.Top (_rgPaletteBuilder)+1, + X = Pos.Right (_rgPaletteBuilder) + 1, + Y = Pos.Top (_rgPaletteBuilder), Value = 8 }; @@ -380,6 +388,13 @@ private void BuildSixelTab () Y = Pos.Top (_popularityThreshold), }; + var l2 = new Label () + { + Text = "Color Distance Algorithm", + Width = Dim.Auto (), + X = Pos.Right (_sixelView), + Y = Pos.Bottom (_rgPaletteBuilder) + 1, + }; _rgDistanceAlgorithm = new RadioGroup () { RadioLabels = new [] @@ -387,15 +402,18 @@ private void BuildSixelTab () "Euclidian", "CIE76" }, - X = Pos.Right (_sixelView), - Y = Pos.Bottom (_rgPaletteBuilder)+1, + X = Pos.Right (_sixelView) + 2, + Y = Pos.Bottom (l2), }; _sixelSupported.Add (lblPxX); _sixelSupported.Add (_pxX); _sixelSupported.Add (lblPxY); _sixelSupported.Add (_pxY); + _sixelSupported.Add (l1); _sixelSupported.Add (_rgPaletteBuilder); + + _sixelSupported.Add (l2); _sixelSupported.Add (_rgDistanceAlgorithm); _sixelSupported.Add (_popularityThreshold); _sixelSupported.Add (lblPopThreshold); From 97da4cd18fc189d0a2d2e353b561b7d19f28cc22 Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 5 Oct 2024 19:41:07 +0100 Subject: [PATCH 44/62] Fix unit test expectations --- UnitTests/Drawing/SixelEncoderTests.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/UnitTests/Drawing/SixelEncoderTests.cs b/UnitTests/Drawing/SixelEncoderTests.cs index 100eed03d4..65d9e423af 100644 --- a/UnitTests/Drawing/SixelEncoderTests.cs +++ b/UnitTests/Drawing/SixelEncoderTests.cs @@ -154,13 +154,12 @@ public void EncodeSixel_12x12GridPattern3x3_ReturnsExpectedSixel () public void EncodeSixel_Transparent12x12_ReturnsExpectedSixel () { string expected = "\u001bP" // Start sixel sequence - + "0;0;0" // Defaults for aspect ratio and grid size + + "0;1;0" // Defaults for aspect ratio and grid size + "q" // Signals beginning of sixel image data + "\"1;1;12;12" // no scaling factors (1x1) and filling 12x12 pixel area + "#0;2;0;0;0" // Black transparent (TODO: Shouldn't really be output this if it is transparent) - // Since all pixels are transparent, the data should just be filled with '?' - + "#0!12?$-" // Fills the transparent line with byte 0 which maps to '?' - + "#0!12?$" // Second band, same fully transparent pixels + // Since all pixels are transparent we don't output any colors at all, so its just newline + + "-" // Nothing on first or second lines + "\u001b\\"; // End sixel sequence // Arrange: Create a 12x12 bitmap filled with fully transparent pixels @@ -185,7 +184,7 @@ public void EncodeSixel_Transparent12x12_ReturnsExpectedSixel () public void EncodeSixel_VerticalMix_TransparentAndColor_ReturnsExpectedSixel () { string expected = "\u001bP" // Start sixel sequence - + "0;0;0" // Defaults for aspect ratio and grid size + + "0;1;0" // Defaults for aspect ratio and grid size (1 indicates support for transparent pixels) + "q" // Signals beginning of sixel image data + "\"1;1;12;12" // No scaling factors (1x1) and filling 12x12 pixel area /* From fd7f994a58b23a9e4bc2b82199c24b634aaed0af Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 5 Oct 2024 21:05:10 +0100 Subject: [PATCH 45/62] Fix GetPaletteBuilder --- UICatalog/Scenarios/Images.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index cb35dfd235..6e319c8521 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -425,8 +425,8 @@ IPaletteBuilder GetPaletteBuilder () { switch (_rgPaletteBuilder.SelectedItem) { - case 0: return new MedianCutPaletteBuilder (GetDistanceAlgorithm ()); - case 1: return new PopularityPaletteWithThreshold (GetDistanceAlgorithm (), _popularityThreshold.Value); + case 0: return new PopularityPaletteWithThreshold (GetDistanceAlgorithm (), _popularityThreshold.Value); + case 1: return new MedianCutPaletteBuilder (GetDistanceAlgorithm ()); default: throw new ArgumentOutOfRangeException (); } } From ccb974b66f8a1cac678e8772dc2e807b851c167e Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 5 Oct 2024 20:09:33 +0100 Subject: [PATCH 46/62] Change to using ansi escape sequences in new standalone class --- Terminal.Gui/Drawing/SixelSupportDetector.cs | 45 ++++++++++++++++++++ UICatalog/Scenarios/Images.cs | 6 +++ 2 files changed, 51 insertions(+) create mode 100644 Terminal.Gui/Drawing/SixelSupportDetector.cs diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs new file mode 100644 index 0000000000..89e0fc108a --- /dev/null +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Terminal.Gui; + +/// +/// Uses ANSII escape sequences to detect whether sixel is supported +/// by the terminal. +/// +public class SixelSupportDetector +{ + public SixelSupport Detect () + { + var darResponse = AnsiEscapeSequenceRequest.ExecuteAnsiRequest (EscSeqUtils.CSI_SendDeviceAttributes); + var result = new SixelSupport (); + result.IsSupported = darResponse.Response.Contains ('4'); + + return result; + } +} + + +public class SixelSupport +{ + /// + /// Whether the current driver supports sixel graphic format. + /// Defaults to false. + /// + public bool IsSupported { get; set; } + + /// + /// The number of pixels of sixel that corresponds to each Col () + /// and each Row (. Defaults to 10x20. + /// + public Size Resolution { get; set; } = new Size (10, 20); + + /// + /// The maximum number of colors that can be included in a sixel image. Defaults + /// to 256. + /// + public int MaxPaletteColors { get; set; } = 256; +} \ No newline at end of file diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 6e319c8521..089ccbd36e 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -61,9 +61,15 @@ public class Images : Scenario private RadioGroup _rgDistanceAlgorithm; private NumericUpDown _popularityThreshold; private SixelToRender _sixelImage; + private SixelSupport _sixelSupport; public override void Main () { + var sixelSupportDetector = new SixelSupportDetector (); + _sixelSupport = sixelSupportDetector.Detect (); + + ConsoleDriver.SupportsSixel = _sixelSupport.IsSupported; + Application.Init (); _win = new() { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; From 78d49fcd849e007cc2abae44d59a8fa77e65cdc9 Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 5 Oct 2024 20:18:21 +0100 Subject: [PATCH 47/62] Fix algorithm to look for exactly 4 not things like 42 etc. --- Terminal.Gui/Drawing/SixelSupportDetector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs index 89e0fc108a..16d933afd0 100644 --- a/Terminal.Gui/Drawing/SixelSupportDetector.cs +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -16,7 +16,7 @@ public SixelSupport Detect () { var darResponse = AnsiEscapeSequenceRequest.ExecuteAnsiRequest (EscSeqUtils.CSI_SendDeviceAttributes); var result = new SixelSupport (); - result.IsSupported = darResponse.Response.Contains ('4'); + result.IsSupported = darResponse.Response.Split (';').Contains ("4"); return result; } From ffe8969b50f67877c4df7bbda2ecd0443da05d26 Mon Sep 17 00:00:00 2001 From: tznind Date: Sat, 5 Oct 2024 21:01:02 +0100 Subject: [PATCH 48/62] Fix new palette order --- UICatalog/Scenarios/Images.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 089ccbd36e..14bdf24ddd 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -166,6 +166,7 @@ private void BtnStartFireOnAccept (object sender, HandledEventArgs e) _fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); _fireEncoder = new SixelEncoder (); + _fireEncoder.Quantizer.MaxColors = Math.Min (_fireEncoder.Quantizer.MaxColors, _sixelSupport.MaxPaletteColors); _fireEncoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette); _fireFrameCounter = 0; @@ -343,7 +344,7 @@ private void BuildSixelTab () { X = Pos.Right (lblPxX), Y = Pos.Bottom (btnStartFire) + 1, - Value = 10 + Value = _sixelSupport.Resolution.Width }; var lblPxY = new Label @@ -357,7 +358,7 @@ private void BuildSixelTab () { X = Pos.Right (lblPxY), Y = Pos.Bottom (_pxX), - Value = 20 + Value = _sixelSupport.Resolution.Height }; var l1 = new Label () @@ -506,6 +507,7 @@ int pixelsPerCellY ) { var encoder = new SixelEncoder (); + encoder.Quantizer.MaxColors = Math.Min (encoder.Quantizer.MaxColors, _sixelSupport.MaxPaletteColors); encoder.Quantizer.PaletteBuildingAlgorithm = GetPaletteBuilder (); encoder.Quantizer.DistanceAlgorithm = GetDistanceAlgorithm (); From fe7e10a130c23bb95f755ee53e489c4bbdac9bb6 Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 6 Oct 2024 19:00:07 +0100 Subject: [PATCH 49/62] Get sixel resolution using CSI 16 t --- .../ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs | 6 ++ Terminal.Gui/Drawing/SixelSupportDetector.cs | 65 ++++++++++++++----- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs index 1eb63e34aa..8bd9709669 100644 --- a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs +++ b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs @@ -1356,6 +1356,12 @@ public enum DECSCUSR_Style /// public const string CSI_ReportDeviceAttributes_Terminator = "c"; + /// + /// CSI 16 t - Request sixel resolution (width and height in pixels) + /// + public static readonly AnsiEscapeSequenceRequest CSI_RequestSixelResolution = new () { Request = CSI + "16t", Terminator = "t" }; + + /// /// CSI 1 8 t | yes | yes | yes | report window size in chars /// https://terminalguide.namepad.de/seq/csi_st-18/ diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs index 16d933afd0..295dcd4ba1 100644 --- a/Terminal.Gui/Drawing/SixelSupportDetector.cs +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -1,45 +1,74 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; namespace Terminal.Gui; /// -/// Uses ANSII escape sequences to detect whether sixel is supported -/// by the terminal. +/// Uses ANSII escape sequences to detect whether sixel is supported +/// by the terminal. /// public class SixelSupportDetector { public SixelSupport Detect () { - var darResponse = AnsiEscapeSequenceRequest.ExecuteAnsiRequest (EscSeqUtils.CSI_SendDeviceAttributes); var result = new SixelSupport (); - result.IsSupported = darResponse.Response.Split (';').Contains ("4"); + + result.IsSupported = + AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_SendDeviceAttributes, out AnsiEscapeSequenceResponse darResponse) + ? darResponse.Response.Split (';').Contains ("4") + : false; + + if (result.IsSupported) + { + // Expect something like: + //[6;20;10t + + bool gotResolutionDirectly = false; + + if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestSixelResolution, out var resolution)) + { + // Terminal supports directly responding with resolution + var match = Regex.Match (resolution.Response, @"\[\d+;(\d+);(\d+)t$"); + + if (match.Success) + { + if (int.TryParse (match.Groups [1].Value, out var ry) && + int.TryParse (match.Groups [2].Value, out var rx)) + { + result.Resolution = new Size (rx, ry); + gotResolutionDirectly = true; + } + } + } + + + if (!gotResolutionDirectly) + { + // TODO: Try pixel/window resolution getting + } + } return result; } } - public class SixelSupport { /// - /// Whether the current driver supports sixel graphic format. - /// Defaults to false. + /// Whether the current driver supports sixel graphic format. + /// Defaults to false. /// public bool IsSupported { get; set; } /// - /// The number of pixels of sixel that corresponds to each Col () - /// and each Row (. Defaults to 10x20. + /// The number of pixels of sixel that corresponds to each Col () + /// and each Row (. Defaults to 10x20. /// - public Size Resolution { get; set; } = new Size (10, 20); + public Size Resolution { get; set; } = new (10, 20); /// - /// The maximum number of colors that can be included in a sixel image. Defaults - /// to 256. + /// The maximum number of colors that can be included in a sixel image. Defaults + /// to 256. /// public int MaxPaletteColors { get; set; } = 256; -} \ No newline at end of file +} From 54308ffb27ea4e41b0a1893f68063a8a2a147ecc Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 6 Oct 2024 19:12:50 +0100 Subject: [PATCH 50/62] Sixel resolution measuring --- .../ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs | 4 +++ Terminal.Gui/Drawing/SixelSupportDetector.cs | 31 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs index 8bd9709669..2065ced28a 100644 --- a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs +++ b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs @@ -1361,6 +1361,10 @@ public enum DECSCUSR_Style /// public static readonly AnsiEscapeSequenceRequest CSI_RequestSixelResolution = new () { Request = CSI + "16t", Terminator = "t" }; + /// + /// CSI 14 t - Request window size in pixels (width x height) + /// + public static readonly AnsiEscapeSequenceRequest CSI_RequestWindowSizeInPixels = new () { Request = CSI + "14t", Terminator = "t" }; /// /// CSI 1 8 t | yes | yes | yes | report window size in chars diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs index 295dcd4ba1..48bc018493 100644 --- a/Terminal.Gui/Drawing/SixelSupportDetector.cs +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -41,10 +41,37 @@ public SixelSupport Detect () } } - if (!gotResolutionDirectly) { - // TODO: Try pixel/window resolution getting + // Fallback to window size in pixels and characters + if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestWindowSizeInPixels, out var pixelSizeResponse) && + AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_ReportTerminalSizeInChars, out var charSizeResponse)) + { + // Example [4;600;1200t + var pixelMatch = Regex.Match (pixelSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$"); + + // Example [8;30;120t + var charMatch = Regex.Match (charSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$"); + + if (pixelMatch.Success && charMatch.Success) + { + // Extract pixel dimensions + if (int.TryParse (pixelMatch.Groups [1].Value, out var pixelHeight) && + int.TryParse (pixelMatch.Groups [2].Value, out var pixelWidth) && + // Extract character dimensions + int.TryParse (charMatch.Groups [1].Value, out var charHeight) && + int.TryParse (charMatch.Groups [2].Value, out var charWidth) && + charWidth != 0 && charHeight != 0) // Avoid divide by zero + { + // Calculate the character cell size in pixels + var cellWidth = pixelWidth / charWidth; + var cellHeight = pixelHeight / charHeight; + + // Set the resolution based on the character cell size + result.Resolution = new Size (cellWidth, cellHeight); + } + } + } } } From 64baeca7b346fe5e792c29cff6b7d286ea2c132f Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 6 Oct 2024 19:20:53 +0100 Subject: [PATCH 51/62] Update sixel status class name and move to new file --- Terminal.Gui/Drawing/SixelSupportDetector.cs | 27 +++----------------- Terminal.Gui/Drawing/SixelSupportResult.cs | 27 ++++++++++++++++++++ UICatalog/Scenarios/Images.cs | 14 +++++----- 3 files changed, 37 insertions(+), 31 deletions(-) create mode 100644 Terminal.Gui/Drawing/SixelSupportResult.cs diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs index 48bc018493..54b7cd5c01 100644 --- a/Terminal.Gui/Drawing/SixelSupportDetector.cs +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -9,9 +9,9 @@ namespace Terminal.Gui; /// public class SixelSupportDetector { - public SixelSupport Detect () + public SixelSupportResult Detect () { - var result = new SixelSupport (); + var result = new SixelSupportResult (); result.IsSupported = AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_SendDeviceAttributes, out AnsiEscapeSequenceResponse darResponse) @@ -77,25 +77,4 @@ public SixelSupport Detect () return result; } -} - -public class SixelSupport -{ - /// - /// Whether the current driver supports sixel graphic format. - /// Defaults to false. - /// - public bool IsSupported { get; set; } - - /// - /// The number of pixels of sixel that corresponds to each Col () - /// and each Row (. Defaults to 10x20. - /// - public Size Resolution { get; set; } = new (10, 20); - - /// - /// The maximum number of colors that can be included in a sixel image. Defaults - /// to 256. - /// - public int MaxPaletteColors { get; set; } = 256; -} +} \ No newline at end of file diff --git a/Terminal.Gui/Drawing/SixelSupportResult.cs b/Terminal.Gui/Drawing/SixelSupportResult.cs new file mode 100644 index 0000000000..d9b8eb03e3 --- /dev/null +++ b/Terminal.Gui/Drawing/SixelSupportResult.cs @@ -0,0 +1,27 @@ +namespace Terminal.Gui; + +/// +/// Describes the discovered state of sixel support and ancillary information +/// e.g. . You can use +/// to discover this information. +/// +public class SixelSupportResult +{ + /// + /// Whether the current driver supports sixel graphic format. + /// Defaults to false. + /// + public bool IsSupported { get; set; } + + /// + /// The number of pixels of sixel that corresponds to each Col () + /// and each Row (. Defaults to 10x20. + /// + public Size Resolution { get; set; } = new (10, 20); + + /// + /// The maximum number of colors that can be included in a sixel image. Defaults + /// to 256. + /// + public int MaxPaletteColors { get; set; } = 256; +} diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 14bdf24ddd..8d1fa13322 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -61,14 +61,14 @@ public class Images : Scenario private RadioGroup _rgDistanceAlgorithm; private NumericUpDown _popularityThreshold; private SixelToRender _sixelImage; - private SixelSupport _sixelSupport; + private SixelSupportResult _sixelSupportResult; public override void Main () { var sixelSupportDetector = new SixelSupportDetector (); - _sixelSupport = sixelSupportDetector.Detect (); + _sixelSupportResult = sixelSupportDetector.Detect (); - ConsoleDriver.SupportsSixel = _sixelSupport.IsSupported; + ConsoleDriver.SupportsSixel = _sixelSupportResult.IsSupported; Application.Init (); _win = new() { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; @@ -166,7 +166,7 @@ private void BtnStartFireOnAccept (object sender, HandledEventArgs e) _fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); _fireEncoder = new SixelEncoder (); - _fireEncoder.Quantizer.MaxColors = Math.Min (_fireEncoder.Quantizer.MaxColors, _sixelSupport.MaxPaletteColors); + _fireEncoder.Quantizer.MaxColors = Math.Min (_fireEncoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); _fireEncoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette); _fireFrameCounter = 0; @@ -344,7 +344,7 @@ private void BuildSixelTab () { X = Pos.Right (lblPxX), Y = Pos.Bottom (btnStartFire) + 1, - Value = _sixelSupport.Resolution.Width + Value = _sixelSupportResult.Resolution.Width }; var lblPxY = new Label @@ -358,7 +358,7 @@ private void BuildSixelTab () { X = Pos.Right (lblPxY), Y = Pos.Bottom (_pxX), - Value = _sixelSupport.Resolution.Height + Value = _sixelSupportResult.Resolution.Height }; var l1 = new Label () @@ -507,7 +507,7 @@ int pixelsPerCellY ) { var encoder = new SixelEncoder (); - encoder.Quantizer.MaxColors = Math.Min (encoder.Quantizer.MaxColors, _sixelSupport.MaxPaletteColors); + encoder.Quantizer.MaxColors = Math.Min (encoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); encoder.Quantizer.PaletteBuildingAlgorithm = GetPaletteBuilder (); encoder.Quantizer.DistanceAlgorithm = GetDistanceAlgorithm (); From a8e3a0ec2bdbfe789cccfa756b87e5cf4bf87407 Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 6 Oct 2024 20:24:47 +0100 Subject: [PATCH 52/62] Update xmldoc --- Terminal.Gui/Drawing/SixelSupportDetector.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs index 54b7cd5c01..c60ffeb530 100644 --- a/Terminal.Gui/Drawing/SixelSupportDetector.cs +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -4,11 +4,18 @@ namespace Terminal.Gui; /// -/// Uses ANSII escape sequences to detect whether sixel is supported +/// Uses Ansi escape sequences to detect whether sixel is supported /// by the terminal. /// public class SixelSupportDetector { + /// + /// Sends Ansi escape sequences to the console to determine whether + /// sixel is supported (and + /// etc). + /// + /// Description of sixel support, may include assumptions where + /// expected response codes are not returned by console. public SixelSupportResult Detect () { var result = new SixelSupportResult (); From 4073ef305cfbcf5c72a72b232dd309e753804b98 Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 7 Oct 2024 09:01:40 +0100 Subject: [PATCH 53/62] Use round to nearest whole number when calculating sixel resolution from window size/chars --- Terminal.Gui/Drawing/SixelSupportDetector.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs index c60ffeb530..9254a96057 100644 --- a/Terminal.Gui/Drawing/SixelSupportDetector.cs +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -71,8 +71,8 @@ public SixelSupportResult Detect () charWidth != 0 && charHeight != 0) // Avoid divide by zero { // Calculate the character cell size in pixels - var cellWidth = pixelWidth / charWidth; - var cellHeight = pixelHeight / charHeight; + var cellWidth = (int)Math.Round ((double)pixelWidth / charWidth); + var cellHeight = (int)Math.Round ((double)pixelHeight / charHeight); // Set the resolution based on the character cell size result.Resolution = new Size (cellWidth, cellHeight); From 61667fbf86c57a884271a501266b4dbe2e2bb337 Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 7 Oct 2024 09:58:19 +0100 Subject: [PATCH 54/62] Refactor SixelSupportDetector for cleaner reading --- Terminal.Gui/Drawing/SixelSupportDetector.cs | 142 ++++++++++++------- Terminal.Gui/Drawing/SixelSupportResult.cs | 8 +- 2 files changed, 101 insertions(+), 49 deletions(-) diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs index 9254a96057..4713d5e259 100644 --- a/Terminal.Gui/Drawing/SixelSupportDetector.cs +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -1,5 +1,4 @@ using System.Text.RegularExpressions; -using Microsoft.CodeAnalysis; namespace Terminal.Gui; @@ -20,68 +19,115 @@ public SixelSupportResult Detect () { var result = new SixelSupportResult (); - result.IsSupported = - AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_SendDeviceAttributes, out AnsiEscapeSequenceResponse darResponse) - ? darResponse.Response.Split (';').Contains ("4") - : false; + result.IsSupported = IsSixelSupportedByDar (); if (result.IsSupported) { - // Expect something like: - //[6;20;10t + if (TryGetResolutionDirectly (out var res)) + { + result.Resolution = res; + } + else if(TryComputeResolution(out res)) + { + result.Resolution = res; + } + + result.SupportsTransparency = IsWindowsTerminal () || IsXtermWithTransparency (); + } - bool gotResolutionDirectly = false; + return result; + } - if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestSixelResolution, out var resolution)) - { - // Terminal supports directly responding with resolution - var match = Regex.Match (resolution.Response, @"\[\d+;(\d+);(\d+)t$"); - if (match.Success) + private bool TryGetResolutionDirectly (out Size resolution) + { + // Expect something like: + //[6;20;10t + + if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestSixelResolution, out var response)) + { + // Terminal supports directly responding with resolution + var match = Regex.Match (response.Response, @"\[\d+;(\d+);(\d+)t$"); + + if (match.Success) + { + if (int.TryParse (match.Groups [1].Value, out var ry) && + int.TryParse (match.Groups [2].Value, out var rx)) { - if (int.TryParse (match.Groups [1].Value, out var ry) && - int.TryParse (match.Groups [2].Value, out var rx)) - { - result.Resolution = new Size (rx, ry); - gotResolutionDirectly = true; - } + resolution = new Size (rx, ry); + + return true; } } + } + + resolution = default; + return false; + } + - if (!gotResolutionDirectly) + private bool TryComputeResolution (out Size resolution) + { + // Fallback to window size in pixels and characters + if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestWindowSizeInPixels, out var pixelSizeResponse) + && AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_ReportTerminalSizeInChars, out var charSizeResponse)) + { + // Example [4;600;1200t + var pixelMatch = Regex.Match (pixelSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$"); + + // Example [8;30;120t + var charMatch = Regex.Match (charSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$"); + + if (pixelMatch.Success && charMatch.Success) { - // Fallback to window size in pixels and characters - if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestWindowSizeInPixels, out var pixelSizeResponse) && - AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_ReportTerminalSizeInChars, out var charSizeResponse)) + // Extract pixel dimensions + if (int.TryParse (pixelMatch.Groups [1].Value, out var pixelHeight) + && int.TryParse (pixelMatch.Groups [2].Value, out var pixelWidth) + && + + // Extract character dimensions + int.TryParse (charMatch.Groups [1].Value, out var charHeight) + && int.TryParse (charMatch.Groups [2].Value, out var charWidth) + && charWidth != 0 + && charHeight != 0) // Avoid divide by zero { - // Example [4;600;1200t - var pixelMatch = Regex.Match (pixelSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$"); - - // Example [8;30;120t - var charMatch = Regex.Match (charSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$"); - - if (pixelMatch.Success && charMatch.Success) - { - // Extract pixel dimensions - if (int.TryParse (pixelMatch.Groups [1].Value, out var pixelHeight) && - int.TryParse (pixelMatch.Groups [2].Value, out var pixelWidth) && - // Extract character dimensions - int.TryParse (charMatch.Groups [1].Value, out var charHeight) && - int.TryParse (charMatch.Groups [2].Value, out var charWidth) && - charWidth != 0 && charHeight != 0) // Avoid divide by zero - { - // Calculate the character cell size in pixels - var cellWidth = (int)Math.Round ((double)pixelWidth / charWidth); - var cellHeight = (int)Math.Round ((double)pixelHeight / charHeight); - - // Set the resolution based on the character cell size - result.Resolution = new Size (cellWidth, cellHeight); - } - } + // Calculate the character cell size in pixels + var cellWidth = (int)Math.Round ((double)pixelWidth / charWidth); + var cellHeight = (int)Math.Round ((double)pixelHeight / charHeight); + + // Set the resolution based on the character cell size + resolution = new Size (cellWidth, cellHeight); + + return true; } } } - return result; + resolution = default; + return false; + } + private bool IsSixelSupportedByDar () + { + return AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_SendDeviceAttributes, out AnsiEscapeSequenceResponse darResponse) + ? darResponse.Response.Split (';').Contains ("4") + : false; + } + + private bool IsWindowsTerminal () + { + return !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable ("WT_SESSION"));; + } + private bool IsXtermWithTransparency () + { + // Check if running in real xterm (XTERM_VERSION is more reliable than TERM) + var xtermVersionStr = Environment.GetEnvironmentVariable ("XTERM_VERSION"); + + // If XTERM_VERSION exists, we are in a real xterm + if (!string.IsNullOrWhiteSpace (xtermVersionStr) && int.TryParse (xtermVersionStr, out var xtermVersion) && xtermVersion >= 370) + { + return true; + } + + return false; } } \ No newline at end of file diff --git a/Terminal.Gui/Drawing/SixelSupportResult.cs b/Terminal.Gui/Drawing/SixelSupportResult.cs index d9b8eb03e3..db42ad2370 100644 --- a/Terminal.Gui/Drawing/SixelSupportResult.cs +++ b/Terminal.Gui/Drawing/SixelSupportResult.cs @@ -8,7 +8,7 @@ public class SixelSupportResult { /// - /// Whether the current driver supports sixel graphic format. + /// Whether the terminal supports sixel graphic format. /// Defaults to false. /// public bool IsSupported { get; set; } @@ -24,4 +24,10 @@ public class SixelSupportResult /// to 256. /// public int MaxPaletteColors { get; set; } = 256; + + /// + /// Whether the terminal supports transparent background sixels. + /// Defaults to false + /// + public bool SupportsTransparency { get; set; } } From 54b737f46914b170bf39c73a14a8d1608ccf8b54 Mon Sep 17 00:00:00 2001 From: tznind Date: Mon, 7 Oct 2024 10:03:22 +0100 Subject: [PATCH 55/62] If not supporting transparency warn user --- UICatalog/Scenarios/Images.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 8d1fa13322..9fb4666daf 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -164,6 +164,20 @@ private void BtnStartFireOnAccept (object sender, HandledEventArgs e) return; } + if (!_sixelSupportResult.SupportsTransparency) + { + if (MessageBox.Query ( + "Transparency Not Supported", + "It looks like your terminal does not support transparent sixel backgrounds. Do you want to try anyway?", + "Yes", + "No") + != 0) + { + return; + } + } + + _fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); _fireEncoder = new SixelEncoder (); _fireEncoder.Quantizer.MaxColors = Math.Min (_fireEncoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); From 9990a552d24d2cc3be5e679cc8a50460b1c0b89b Mon Sep 17 00:00:00 2001 From: tznind Date: Thu, 10 Oct 2024 10:32:08 +0100 Subject: [PATCH 56/62] Fix for accepting --- .../ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs | 3 +++ Terminal.Gui/Drawing/AssumeSupportDetector.cs | 20 +++++++++++++++++++ Terminal.Gui/Drawing/ISixelSupportDetector.cs | 15 ++++++++++++++ Terminal.Gui/Drawing/SixelSupportDetector.cs | 6 +++--- UICatalog/Scenarios/Images.cs | 17 ++++++++-------- 5 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 Terminal.Gui/Drawing/AssumeSupportDetector.cs create mode 100644 Terminal.Gui/Drawing/ISixelSupportDetector.cs diff --git a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs index 2065ced28a..6330c33702 100644 --- a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs +++ b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs @@ -1356,6 +1356,8 @@ public enum DECSCUSR_Style /// public const string CSI_ReportDeviceAttributes_Terminator = "c"; + /* + TODO: depends on https://github.com/gui-cs/Terminal.Gui/pull/3768 /// /// CSI 16 t - Request sixel resolution (width and height in pixels) /// @@ -1365,6 +1367,7 @@ public enum DECSCUSR_Style /// CSI 14 t - Request window size in pixels (width x height) /// public static readonly AnsiEscapeSequenceRequest CSI_RequestWindowSizeInPixels = new () { Request = CSI + "14t", Terminator = "t" }; + */ /// /// CSI 1 8 t | yes | yes | yes | report window size in chars diff --git a/Terminal.Gui/Drawing/AssumeSupportDetector.cs b/Terminal.Gui/Drawing/AssumeSupportDetector.cs new file mode 100644 index 0000000000..c01b03f1cf --- /dev/null +++ b/Terminal.Gui/Drawing/AssumeSupportDetector.cs @@ -0,0 +1,20 @@ +namespace Terminal.Gui; + +/// +/// Implementation of that assumes best +/// case scenario (full support including transparency with 10x20 resolution). +/// +public class AssumeSupportDetector : ISixelSupportDetector +{ + /// + public SixelSupportResult Detect () + { + return new SixelSupportResult + { + IsSupported = true, + MaxPaletteColors = 256, + Resolution = new Size (10, 20), + SupportsTransparency = true + }; + } +} diff --git a/Terminal.Gui/Drawing/ISixelSupportDetector.cs b/Terminal.Gui/Drawing/ISixelSupportDetector.cs new file mode 100644 index 0000000000..07ca435084 --- /dev/null +++ b/Terminal.Gui/Drawing/ISixelSupportDetector.cs @@ -0,0 +1,15 @@ +namespace Terminal.Gui; + +/// +/// Interface for detecting sixel support. Either through +/// ansi requests to terminal or config file etc. +/// +public interface ISixelSupportDetector +{ + /// + /// Gets the supported sixel state e.g. by sending Ansi escape sequences + /// or from a config file etc. + /// + /// Description of sixel support. + public SixelSupportResult Detect (); +} diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs index 4713d5e259..d6044ff483 100644 --- a/Terminal.Gui/Drawing/SixelSupportDetector.cs +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -1,12 +1,12 @@ using System.Text.RegularExpressions; namespace Terminal.Gui; - +/* TODO : Depends on https://github.com/gui-cs/Terminal.Gui/pull/3768 /// /// Uses Ansi escape sequences to detect whether sixel is supported /// by the terminal. /// -public class SixelSupportDetector +public class SixelSupportDetector : ISixelSupportDetector { /// /// Sends Ansi escape sequences to the console to determine whether @@ -130,4 +130,4 @@ private bool IsXtermWithTransparency () return false; } -} \ No newline at end of file +}*/ \ No newline at end of file diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 9fb4666daf..727be180d2 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -65,7 +65,8 @@ public class Images : Scenario public override void Main () { - var sixelSupportDetector = new SixelSupportDetector (); + // TODO: Change to the one that uses Ansi Requests later + var sixelSupportDetector = new AssumeSupportDetector (); _sixelSupportResult = sixelSupportDetector.Detect (); ConsoleDriver.SupportsSixel = _sixelSupportResult.IsSupported; @@ -143,7 +144,7 @@ public override void Main () SetupSixelSupported (cbSupportsSixel.CheckedState == CheckState.Checked); - btnOpenImage.Accept += OpenImage; + btnOpenImage.Accepting += OpenImage; _win.Add (_tabView); Application.Run (_win); @@ -157,7 +158,7 @@ private void SetupSixelSupported (bool isSupported) _tabView.SetNeedsDisplay (); } - private void BtnStartFireOnAccept (object sender, HandledEventArgs e) + private void BtnStartFireOnAccept (object sender, CommandEventArgs e) { if (_fire != null) { @@ -233,7 +234,7 @@ protected override void Dispose (bool disposing) Application.Sixel.Clear (); } - private void OpenImage (object sender, HandledEventArgs e) + private void OpenImage (object sender, CommandEventArgs e) { var ofd = new OpenDialog { Title = "Open Image", AllowsMultipleSelection = false }; Application.Run (ofd); @@ -334,7 +335,7 @@ private void BuildSixelTab () Y = 0, Text = "Output Sixel", Width = Dim.Auto () }; - btnSixel.Accept += OutputSixelButtonClick; + btnSixel.Accepting += OutputSixelButtonClick; _sixelSupported.Add (btnSixel); var btnStartFire = new Button @@ -343,7 +344,7 @@ private void BuildSixelTab () Y = Pos.Bottom (btnSixel), Text = "Start Fire" }; - btnStartFire.Accept += BtnStartFireOnAccept; + btnStartFire.Accepting += BtnStartFireOnAccept; _sixelSupported.Add (btnStartFire); @@ -462,7 +463,7 @@ IColorDistance GetDistanceAlgorithm () } } - private void OutputSixelButtonClick (object sender, HandledEventArgs e) + private void OutputSixelButtonClick (object sender, CommandEventArgs e) { if (_imageView.FullResImage == null) { @@ -555,7 +556,7 @@ int pixelsPerCellY Text = "Ok" }; - btn.Accept += (s, e) => Application.RequestStop (); + btn.Accepting += (s, e) => Application.RequestStop (); dlg.Add (pv); dlg.AddButton (btn); Application.Run (dlg); From 64d286c9b4ce60923730683c40db465a5bbe17a5 Mon Sep 17 00:00:00 2001 From: tznind Date: Thu, 10 Oct 2024 10:42:20 +0100 Subject: [PATCH 57/62] Remove ConsoleDriver.SupportsSixel --- Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs | 3 --- Terminal.Gui/ConsoleDrivers/NetDriver.cs | 17 ++++------------- Terminal.Gui/ConsoleDrivers/WindowsDriver.cs | 10 +++------- UICatalog/Scenarios/Images.cs | 6 ++---- 4 files changed, 9 insertions(+), 27 deletions(-) diff --git a/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs b/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs index 617c1be61b..9f2a454654 100644 --- a/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs @@ -484,9 +484,6 @@ public virtual void Move (int col, int row) /// Gets whether the supports TrueColor output. public virtual bool SupportsTrueColor => true; - // TODO: make not static TODO: gets set in mouse logic in net driver :/ - public static bool SupportsSixel { get; set; } - // TODO: This makes ConsoleDriver dependent on Application, which is not ideal. This should be moved to Application. // BUGBUG: Application.Force16Colors should be bool? so if SupportsTrueColor and Application.Force16Colors == false, this doesn't override /// diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index b5729406ce..ac6df8a011 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -632,9 +632,6 @@ private void HandleRequestResponseEvent (string c1Control, string code, string [ break; } - break; - case EscSeqUtils.CSI_ReportDeviceAttributes_Terminator: - ConsoleDriver.SupportsSixel = values.Any (v => v == "4"); break; default: EnqueueRequestResponseEvent (c1Control, code, values, terminating); @@ -1024,15 +1021,12 @@ public override void UpdateScreen () Console.Write (output); } - if (ConsoleDriver.SupportsSixel) + foreach (var s in Application.Sixel) { - foreach (var s in Application.Sixel) + if (!string.IsNullOrWhiteSpace (s.SixelData)) { - if (!string.IsNullOrWhiteSpace (s.SixelData)) - { - SetCursorPosition (s.ScreenPosition.X, s.ScreenPosition.Y); - Console.Write (s.SixelData); - } + SetCursorPosition (s.ScreenPosition.X, s.ScreenPosition.Y); + Console.Write (s.SixelData); } } } @@ -1141,9 +1135,6 @@ internal override MainLoop Init () _mainLoopDriver = new NetMainLoop (this); _mainLoopDriver.ProcessInput = ProcessInput; - _mainLoopDriver._netEvents.EscSeqRequests.Add ("c"); - // Determine if sixel is supported - Console.Out.Write (EscSeqUtils.CSI_SendDeviceAttributes); return new MainLoop (_mainLoopDriver); } diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs index e45f85ba3f..9feb1ab348 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver.cs @@ -129,14 +129,10 @@ public bool WriteToConsole (Size size, ExtendedCharInfo [] charInfoBuffer, Coord _lastWrite = s; - if (ConsoleDriver.SupportsSixel) + foreach (var sixel in Application.Sixel) { - foreach (var sixel in Application.Sixel) - { - SetCursorPosition (new Coord ((short)sixel.ScreenPosition.X, (short)sixel.ScreenPosition.Y)); - WriteConsole (_screenBuffer, sixel.SixelData, (uint)sixel.SixelData.Length, out uint _, nint.Zero); - - } + SetCursorPosition (new Coord ((short)sixel.ScreenPosition.X, (short)sixel.ScreenPosition.Y)); + WriteConsole (_screenBuffer, sixel.SixelData, (uint)sixel.SixelData.Length, out uint _, nint.Zero); } } diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 727be180d2..1522822bae 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -69,8 +69,6 @@ public override void Main () var sixelSupportDetector = new AssumeSupportDetector (); _sixelSupportResult = sixelSupportDetector.Detect (); - ConsoleDriver.SupportsSixel = _sixelSupportResult.IsSupported; - Application.Init (); _win = new() { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; @@ -103,7 +101,7 @@ public override void Main () { X = Pos.Right (lblDriverName) + 2, Y = 1, - CheckedState = ConsoleDriver.SupportsSixel + CheckedState = _sixelSupportResult.IsSupported ? CheckState.Checked : CheckState.UnChecked, Text = "Supports Sixel" @@ -111,7 +109,7 @@ public override void Main () cbSupportsSixel.CheckedStateChanging += (s, e) => { - ConsoleDriver.SupportsSixel = e.NewValue == CheckState.Checked; + _sixelSupportResult.IsSupported = e.NewValue == CheckState.Checked; SetupSixelSupported (e.NewValue == CheckState.Checked); }; From 5a6aae694a62f3871fe13ac95a77acf24e126e11 Mon Sep 17 00:00:00 2001 From: tznind Date: Thu, 10 Oct 2024 12:36:25 +0100 Subject: [PATCH 58/62] Fix warnings and tidup code --- Terminal.Gui/ConsoleDrivers/NetDriver.cs | 1 - Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 5 +++ .../Quant/PopularityPaletteWithThreshold.cs | 6 +++ Terminal.Gui/Drawing/SixelEncoder.cs | 44 +++++++------------ Terminal.Gui/Drawing/SixelSupportResult.cs | 2 +- 5 files changed, 27 insertions(+), 31 deletions(-) diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index ac6df8a011..7a9d17f8bb 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -1339,7 +1339,6 @@ private bool SetCursorPosition (int col, int row) } private CursorVisibility? _cachedCursorVisibility; - private static bool _supportsSixel; public override void UpdateCursor () { diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index ac51399e29..df362f4e0a 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -32,6 +32,11 @@ public class ColorQuantizer private readonly ConcurrentDictionary _nearestColorCache = new (); + /// + /// Builds a of colors that most represent the colors used in image. + /// This is based on the currently configured . + /// + /// public void BuildPalette (Color [,] pixels) { List allColors = new List (); diff --git a/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs index 9e856b96c4..6941d0adde 100644 --- a/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs +++ b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs @@ -12,6 +12,11 @@ public class PopularityPaletteWithThreshold : IPaletteBuilder private readonly IColorDistance _colorDistance; private readonly double _mergeThreshold; + /// + /// Creates a new instance with the given color grouping parameters. + /// + /// Determines which different colors can be considered the same. + /// Threshold for merging two colors together. public PopularityPaletteWithThreshold (IColorDistance colorDistance, double mergeThreshold) { _colorDistance = colorDistance; @@ -62,6 +67,7 @@ public List BuildPalette (List colors, int maxColors) /// Merge colors in the histogram if they are within the threshold distance /// /// + /// /// private Dictionary MergeSimilarColors (Dictionary colorHistogram, int maxColors) { diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index 6681957368..c05618e8c4 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -114,7 +114,6 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi for (int x = 0; x < width; ++x) { Array.Clear (code, 0, usedColorIdx.Count); - bool anyNonTransparentPixel = false; // Track if any non-transparent pixels are found in this column // Process each row in the 6-pixel high band for (int row = 0; row < bandHeight; ++row) @@ -127,10 +126,6 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi { continue; } - else - { - anyNonTransparentPixel = true; - } if (slots [colorIndex] == -1) { @@ -147,27 +142,6 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi code [slots [colorIndex]] |= (byte)(1 << row); // Accumulate SIXEL data } - /* - // If no non-transparent pixels are found in the entire column, it's fully transparent - if (!anyNonTransparentPixel) - { - // Emit fully transparent pixel data: #0!?$ - result.Append ($"#0!{width}?"); - - // Add the line terminator: use "$-" if it's not the last line, "$" if it's the last line - if (x < width - 1) - { - result.Append ("$-"); - } - else - { - result.Append ("$"); - } - - // Skip to the next column as we have already handled transparency - continue; - }*/ - // Handle transitions between columns for (int j = 0; j < usedColorIdx.Count; ++j) { @@ -209,9 +183,21 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi private static string CodeToSixel (int code, int repeat) { char c = (char)(code + 63); - if (repeat > 3) return "!" + repeat + c; - if (repeat == 3) return c.ToString () + c + c; - if (repeat == 2) return c.ToString () + c; + if (repeat > 3) + { + return "!" + repeat + c; + } + + if (repeat == 3) + { + return c.ToString () + c + c; + } + + if (repeat == 2) + { + return c.ToString () + c; + } + return c.ToString (); } diff --git a/Terminal.Gui/Drawing/SixelSupportResult.cs b/Terminal.Gui/Drawing/SixelSupportResult.cs index db42ad2370..6bee9f3201 100644 --- a/Terminal.Gui/Drawing/SixelSupportResult.cs +++ b/Terminal.Gui/Drawing/SixelSupportResult.cs @@ -2,7 +2,7 @@ /// /// Describes the discovered state of sixel support and ancillary information -/// e.g. . You can use +/// e.g. . You can use any /// to discover this information. /// public class SixelSupportResult From bcdb11e2f61983ee5ab95a8902b4af450bec76d7 Mon Sep 17 00:00:00 2001 From: tznind Date: Thu, 10 Oct 2024 12:41:43 +0100 Subject: [PATCH 59/62] Run TidyCode on all new classes --- Terminal.Gui/Drawing/AssumeSupportDetector.cs | 10 +-- Terminal.Gui/Drawing/ISixelSupportDetector.cs | 8 +- Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 37 ++++----- .../Drawing/Quant/EuclideanColorDistance.cs | 30 ++++---- Terminal.Gui/Drawing/Quant/IColorDistance.cs | 10 +-- Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs | 12 +-- .../Quant/PopularityPaletteWithThreshold.cs | 16 ++-- Terminal.Gui/Drawing/SixelEncoder.cs | 75 ++++++++++--------- Terminal.Gui/Drawing/SixelSupportResult.cs | 6 +- Terminal.Gui/Drawing/SixelToRender.cs | 10 +-- UICatalog/Scenarios/Images.cs | 61 +++++++-------- .../PopularityPaletteWithThresholdTests.cs | 10 +-- 12 files changed, 145 insertions(+), 140 deletions(-) diff --git a/Terminal.Gui/Drawing/AssumeSupportDetector.cs b/Terminal.Gui/Drawing/AssumeSupportDetector.cs index c01b03f1cf..46081714ac 100644 --- a/Terminal.Gui/Drawing/AssumeSupportDetector.cs +++ b/Terminal.Gui/Drawing/AssumeSupportDetector.cs @@ -1,19 +1,19 @@ namespace Terminal.Gui; /// -/// Implementation of that assumes best -/// case scenario (full support including transparency with 10x20 resolution). +/// Implementation of that assumes best +/// case scenario (full support including transparency with 10x20 resolution). /// public class AssumeSupportDetector : ISixelSupportDetector { - /// + /// public SixelSupportResult Detect () { - return new SixelSupportResult + return new() { IsSupported = true, MaxPaletteColors = 256, - Resolution = new Size (10, 20), + Resolution = new (10, 20), SupportsTransparency = true }; } diff --git a/Terminal.Gui/Drawing/ISixelSupportDetector.cs b/Terminal.Gui/Drawing/ISixelSupportDetector.cs index 07ca435084..eb0bb9f120 100644 --- a/Terminal.Gui/Drawing/ISixelSupportDetector.cs +++ b/Terminal.Gui/Drawing/ISixelSupportDetector.cs @@ -1,14 +1,14 @@ namespace Terminal.Gui; /// -/// Interface for detecting sixel support. Either through -/// ansi requests to terminal or config file etc. +/// Interface for detecting sixel support. Either through +/// ansi requests to terminal or config file etc. /// public interface ISixelSupportDetector { /// - /// Gets the supported sixel state e.g. by sending Ansi escape sequences - /// or from a config file etc. + /// Gets the supported sixel state e.g. by sending Ansi escape sequences + /// or from a config file etc. /// /// Description of sixel support. public SixelSupportResult Detect (); diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index df362f4e0a..5aa10c652d 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -3,49 +3,49 @@ namespace Terminal.Gui; /// -/// Translates colors in an image into a Palette of up to colors (typically 256). +/// Translates colors in an image into a Palette of up to colors (typically 256). /// public class ColorQuantizer { /// - /// Gets the current colors in the palette based on the last call to - /// . + /// Gets the current colors in the palette based on the last call to + /// . /// public IReadOnlyCollection Palette { get; private set; } = new List (); /// - /// Gets or sets the maximum number of colors to put into the . - /// Defaults to 256 (the maximum for sixel images). + /// Gets or sets the maximum number of colors to put into the . + /// Defaults to 256 (the maximum for sixel images). /// public int MaxColors { get; set; } = 256; /// - /// Gets or sets the algorithm used to map novel colors into existing - /// palette colors (closest match). Defaults to + /// Gets or sets the algorithm used to map novel colors into existing + /// palette colors (closest match). Defaults to /// public IColorDistance DistanceAlgorithm { get; set; } = new EuclideanColorDistance (); /// - /// Gets or sets the algorithm used to build the . + /// Gets or sets the algorithm used to build the . /// - public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (),8) ; + public IPaletteBuilder PaletteBuildingAlgorithm { get; set; } = new PopularityPaletteWithThreshold (new EuclideanColorDistance (), 8); private readonly ConcurrentDictionary _nearestColorCache = new (); /// - /// Builds a of colors that most represent the colors used in image. - /// This is based on the currently configured . + /// Builds a of colors that most represent the colors used in image. + /// This is based on the currently configured . /// /// public void BuildPalette (Color [,] pixels) { - List allColors = new List (); + List allColors = new (); int width = pixels.GetLength (0); int height = pixels.GetLength (1); - for (int x = 0; x < width; x++) + for (var x = 0; x < width; x++) { - for (int y = 0; y < height; y++) + for (var y = 0; y < height; y++) { allColors.Add (pixels [x, y]); } @@ -57,14 +57,14 @@ public void BuildPalette (Color [,] pixels) public int GetNearestColor (Color toTranslate) { - if (_nearestColorCache.TryGetValue (toTranslate, out var cachedAnswer)) + if (_nearestColorCache.TryGetValue (toTranslate, out int cachedAnswer)) { return cachedAnswer; } // Simple nearest color matching based on DistanceAlgorithm - double minDistance = double.MaxValue; - int nearestIndex = 0; + var minDistance = double.MaxValue; + var nearestIndex = 0; for (var index = 0; index < Palette.Count; index++) { @@ -79,6 +79,7 @@ public int GetNearestColor (Color toTranslate) } _nearestColorCache.TryAdd (toTranslate, nearestIndex); + return nearestIndex; } -} \ No newline at end of file +} diff --git a/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs b/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs index e04a63972c..935d598262 100644 --- a/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs +++ b/Terminal.Gui/Drawing/Quant/EuclideanColorDistance.cs @@ -1,20 +1,21 @@ 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. +/// +/// 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 { @@ -24,6 +25,7 @@ public double CalculateDistance (Color c1, Color c2) int rDiff = c1.R - c2.R; int gDiff = c1.G - c2.G; int bDiff = c1.B - c2.B; + return Math.Sqrt (rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); } } diff --git a/Terminal.Gui/Drawing/Quant/IColorDistance.cs b/Terminal.Gui/Drawing/Quant/IColorDistance.cs index 8926943445..fb8dc3aa23 100644 --- a/Terminal.Gui/Drawing/Quant/IColorDistance.cs +++ b/Terminal.Gui/Drawing/Quant/IColorDistance.cs @@ -1,15 +1,15 @@ namespace Terminal.Gui; /// -/// Interface for algorithms that compute the relative distance between pairs of colors. -/// This is used for color matching to a limited palette, such as in Sixel rendering. +/// Interface for algorithms that compute the relative distance between pairs of colors. +/// This is used for color matching to a limited palette, such as in Sixel rendering. /// public interface IColorDistance { /// - /// Computes a similarity metric between two instances. - /// A larger value indicates more dissimilar colors, while a smaller value indicates more similar colors. - /// The metric is internally consistent for the given algorithm. + /// Computes a similarity metric between two instances. + /// A larger value indicates more dissimilar colors, while a smaller value indicates more similar colors. + /// The metric is internally consistent for the given algorithm. /// /// The first color. /// The second color. diff --git a/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs index 999297cff0..232842d998 100644 --- a/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs +++ b/Terminal.Gui/Drawing/Quant/IPaletteBuilder.cs @@ -1,16 +1,18 @@ namespace Terminal.Gui; /// -/// Builds a palette of a given size for a given set of input colors. +/// Builds a palette of a given size for a given set of input colors. /// public interface IPaletteBuilder { /// - /// Reduce the number of to (or less) - /// using an appropriate selection algorithm. + /// Reduce the number of to (or less) + /// using an appropriate selection algorithm. /// - /// Color of every pixel in the image. Contains duplication in order - /// to support algorithms that weigh how common a color is. + /// + /// Color of every pixel in the image. Contains duplication in order + /// to support algorithms that weigh how common a color is. + /// /// The maximum number of colours that should be represented. /// List BuildPalette (List colors, int maxColors); diff --git a/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs index 6941d0adde..0a7e8a0234 100644 --- a/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs +++ b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs @@ -2,10 +2,10 @@ 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. +/// 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 { @@ -13,7 +13,7 @@ public class PopularityPaletteWithThreshold : IPaletteBuilder private readonly double _mergeThreshold; /// - /// Creates a new instance with the given color grouping parameters. + /// Creates a new instance with the given color grouping parameters. /// /// Determines which different colors can be considered the same. /// Threshold for merging two colors together. @@ -31,7 +31,7 @@ public List BuildPalette (List colors, int maxColors) } // Step 1: Build the histogram of colors (count occurrences) - Dictionary colorHistogram = new Dictionary (); + Dictionary colorHistogram = new (); foreach (Color color in colors) { @@ -64,14 +64,14 @@ public List BuildPalette (List colors, int maxColors) } /// - /// Merge colors in the histogram if they are within the threshold distance + /// Merge colors in the histogram if they are within the threshold distance /// /// /// /// private Dictionary MergeSimilarColors (Dictionary colorHistogram, int maxColors) { - Dictionary mergedHistogram = new Dictionary (); + Dictionary mergedHistogram = new (); foreach (KeyValuePair entry in colorHistogram) { diff --git a/Terminal.Gui/Drawing/SixelEncoder.cs b/Terminal.Gui/Drawing/SixelEncoder.cs index c05618e8c4..70d9a44bcf 100644 --- a/Terminal.Gui/Drawing/SixelEncoder.cs +++ b/Terminal.Gui/Drawing/SixelEncoder.cs @@ -4,12 +4,10 @@ // libsixel (C/C++) - https://github.com/saitoha/libsixel // Copyright (c) 2014-2016 Hayaki Saito @license MIT -using Terminal.Gui; - namespace Terminal.Gui; /// -/// Encodes a images into the sixel console image output format. +/// Encodes a images into the sixel console image output format. /// public class SixelEncoder { @@ -35,32 +33,29 @@ e.g. to draw more color layers. */ - /// - /// Gets or sets the quantizer responsible for building a representative - /// limited color palette for images and for mapping novel colors in - /// images to their closest palette color + /// Gets or sets the quantizer responsible for building a representative + /// limited color palette for images and for mapping novel colors in + /// images to their closest palette color /// public ColorQuantizer Quantizer { get; set; } = new (); /// - /// Encode the given bitmap into sixel encoding + /// Encode the given bitmap into sixel encoding /// /// /// public string EncodeSixel (Color [,] pixels) { - const string start = "\u001bP"; // Start sixel sequence - - string defaultRatios = this.AnyHasAlphaOfZero(pixels) ? "0;1;0": "0;0;0"; // Defaults for aspect ratio and grid size + string defaultRatios = AnyHasAlphaOfZero (pixels) ? "0;1;0" : "0;0;0"; // Defaults for aspect ratio and grid size const string completeStartSequence = "q"; // Signals beginning of sixel image data const string noScaling = "\"1;1;"; // no scaling factors (1x1); string fillArea = GetFillArea (pixels); - string pallette = GetColorPalette (pixels ); + string pallette = GetColorPalette (pixels); string pixelData = WriteSixel (pixels); @@ -71,15 +66,14 @@ public string EncodeSixel (Color [,] pixels) private string WriteSixel (Color [,] pixels) { - - StringBuilder sb = new StringBuilder (); + var sb = new StringBuilder (); int height = pixels.GetLength (1); int width = pixels.GetLength (0); // Iterate over each 'row' of the image. Because each sixel write operation // outputs a screen area 6 pixels high (and 1+ across) we must process the image // 6 'y' units at once (1 band) - for (int y = 0; y < height; y += 6) + for (var y = 0; y < height; y += 6) { sb.Append (ProcessBand (pixels, y, Math.Min (6, height - y), width)); @@ -107,18 +101,18 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi Array.Fill (accu, (ushort)1); Array.Fill (slots, (short)-1); - var usedColorIdx = new List (); - var targets = new List> (); + List usedColorIdx = new List (); + List> targets = new List> (); // Process columns within the band - for (int x = 0; x < width; ++x) + for (var x = 0; x < width; ++x) { Array.Clear (code, 0, usedColorIdx.Count); // Process each row in the 6-pixel high band - for (int row = 0; row < bandHeight; ++row) + for (var row = 0; row < bandHeight; ++row) { - var color = pixels [x, startY + row]; + Color color = pixels [x, startY + row]; int colorIndex = Quantizer.GetNearestColor (color); @@ -129,12 +123,14 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi if (slots [colorIndex] == -1) { - targets.Add (new List ()); + targets.Add (new ()); + if (x > 0) { last [usedColorIdx.Count] = 0; accu [usedColorIdx.Count] = (ushort)x; } + slots [colorIndex] = (short)usedColorIdx.Count; usedColorIdx.Add (colorIndex); } @@ -143,7 +139,7 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi } // Handle transitions between columns - for (int j = 0; j < usedColorIdx.Count; ++j) + for (var j = 0; j < usedColorIdx.Count; ++j) { if (code [j] == last [j]) { @@ -155,6 +151,7 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi { targets [j].Add (CodeToSixel (last [j], accu [j])); } + last [j] = (sbyte)code [j]; accu [j] = 1; } @@ -162,7 +159,7 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi } // Process remaining data for this band - for (int j = 0; j < usedColorIdx.Count; ++j) + for (var j = 0; j < usedColorIdx.Count; ++j) { if (last [j] != 0) { @@ -172,7 +169,8 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi // Build the final output for this band var result = new StringBuilder (); - for (int j = 0; j < usedColorIdx.Count; ++j) + + for (var j = 0; j < usedColorIdx.Count; ++j) { result.Append ($"#{usedColorIdx [j]}{string.Join ("", targets [j])}$"); } @@ -182,7 +180,8 @@ private string ProcessBand (Color [,] pixels, int startY, int bandHeight, int wi private static string CodeToSixel (int code, int repeat) { - char c = (char)(code + 63); + var c = (char)(code + 63); + if (repeat > 3) { return "!" + repeat + c; @@ -205,16 +204,18 @@ private string GetColorPalette (Color [,] pixels) { Quantizer.BuildPalette (pixels); - StringBuilder paletteSb = new StringBuilder (); + var paletteSb = new StringBuilder (); - for (int i = 0; i < Quantizer.Palette.Count; i++) + for (var i = 0; i < Quantizer.Palette.Count; i++) { - var color = Quantizer.Palette.ElementAt (i); - paletteSb.AppendFormat ("#{0};2;{1};{2};{3}", - i, - color.R * 100 / 255, - color.G * 100 / 255, - color.B * 100 / 255); + Color color = Quantizer.Palette.ElementAt (i); + + paletteSb.AppendFormat ( + "#{0};2;{1};{2};{3}", + i, + color.R * 100 / 255, + color.G * 100 / 255, + color.B * 100 / 255); } return paletteSb.ToString (); @@ -227,15 +228,16 @@ private string GetFillArea (Color [,] pixels) return $"{widthInChars};{heightInChars}"; } + private bool AnyHasAlphaOfZero (Color [,] pixels) { int width = pixels.GetLength (0); int height = pixels.GetLength (1); // Loop through each pixel in the 2D array - for (int x = 0; x < width; x++) + for (var x = 0; x < width; x++) { - for (int y = 0; y < height; y++) + for (var y = 0; y < height; y++) { // Check if the alpha component (A) is 0 if (pixels [x, y].A == 0) @@ -244,6 +246,7 @@ private bool AnyHasAlphaOfZero (Color [,] pixels) } } } + return false; // No pixel with A of 0 was found } -} \ No newline at end of file +} diff --git a/Terminal.Gui/Drawing/SixelSupportResult.cs b/Terminal.Gui/Drawing/SixelSupportResult.cs index 6bee9f3201..bb8a61e0d2 100644 --- a/Terminal.Gui/Drawing/SixelSupportResult.cs +++ b/Terminal.Gui/Drawing/SixelSupportResult.cs @@ -1,9 +1,9 @@ namespace Terminal.Gui; /// -/// Describes the discovered state of sixel support and ancillary information -/// e.g. . You can use any -/// to discover this information. +/// Describes the discovered state of sixel support and ancillary information +/// e.g. . You can use any +/// to discover this information. /// public class SixelSupportResult { diff --git a/Terminal.Gui/Drawing/SixelToRender.cs b/Terminal.Gui/Drawing/SixelToRender.cs index dc002c7efb..dedd399ef9 100644 --- a/Terminal.Gui/Drawing/SixelToRender.cs +++ b/Terminal.Gui/Drawing/SixelToRender.cs @@ -1,19 +1,19 @@ namespace Terminal.Gui; /// -/// Describes a request to render a given at a given . -/// Requires that the terminal and both support sixel. +/// Describes a request to render a given at a given . +/// Requires that the terminal and both support sixel. /// public class SixelToRender { /// - /// gets or sets the encoded sixel data. Use to convert bitmaps - /// into encoded sixel data. + /// gets or sets the encoded sixel data. Use to convert bitmaps + /// into encoded sixel data. /// public string SixelData { get; set; } /// - /// gets or sets where to move the cursor to before outputting the . + /// gets or sets where to move the cursor to before outputting the . /// public Point ScreenPosition { get; set; } } diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 1522822bae..13a552ee3a 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.ComponentModel; using System.IO; using System.Linq; using System.Text; @@ -70,7 +69,7 @@ public override void Main () _sixelSupportResult = sixelSupportDetector.Detect (); Application.Init (); - _win = new() { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; + _win = new () { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; bool canTrueColor = Application.Driver?.SupportsTrueColor ?? false; @@ -79,7 +78,7 @@ public override void Main () DisplayText = "Basic" }; - _tabSixel = new() + _tabSixel = new () { DisplayText = "Sixel" }; @@ -129,7 +128,7 @@ public override void Main () var btnOpenImage = new Button { X = Pos.Right (cbUseTrueColor) + 2, Y = 0, Text = "Open Image" }; _win.Add (btnOpenImage); - _tabView = new() + _tabView = new () { Y = Pos.Bottom (btnOpenImage), Width = Dim.Fill (), Height = Dim.Fill () }; @@ -166,19 +165,18 @@ private void BtnStartFireOnAccept (object sender, CommandEventArgs e) if (!_sixelSupportResult.SupportsTransparency) { if (MessageBox.Query ( - "Transparency Not Supported", - "It looks like your terminal does not support transparent sixel backgrounds. Do you want to try anyway?", - "Yes", - "No") + "Transparency Not Supported", + "It looks like your terminal does not support transparent sixel backgrounds. Do you want to try anyway?", + "Yes", + "No") != 0) { return; } } - - _fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); - _fireEncoder = new SixelEncoder (); + _fire = new (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); + _fireEncoder = new (); _fireEncoder.Quantizer.MaxColors = Math.Min (_fireEncoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); _fireEncoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette); @@ -294,14 +292,14 @@ private void BuildBasicTab (Tab tabBasic) private void BuildSixelTab () { - _sixelSupported = new() + _sixelSupported = new () { Width = Dim.Fill (), Height = Dim.Fill (), CanFocus = true }; - _sixelNotSupported = new() + _sixelNotSupported = new () { Width = Dim.Fill (), Height = Dim.Fill (), @@ -318,7 +316,7 @@ private void BuildSixelTab () VerticalTextAlignment = Alignment.Center }); - _sixelView = new() + _sixelView = new () { Width = Dim.Percent (50), Height = Dim.Fill (), @@ -345,7 +343,6 @@ private void BuildSixelTab () btnStartFire.Accepting += BtnStartFireOnAccept; _sixelSupported.Add (btnStartFire); - var lblPxX = new Label { X = Pos.Right (_sixelView), @@ -353,7 +350,7 @@ private void BuildSixelTab () Text = "Pixels per Col:" }; - _pxX = new() + _pxX = new () { X = Pos.Right (lblPxX), Y = Pos.Bottom (btnStartFire) + 1, @@ -367,27 +364,27 @@ private void BuildSixelTab () Text = "Pixels per Row:" }; - _pxY = new() + _pxY = new () { X = Pos.Right (lblPxY), Y = Pos.Bottom (_pxX), Value = _sixelSupportResult.Resolution.Height }; - var l1 = new Label () + var l1 = new Label { Text = "Palette Building Algorithm", Width = Dim.Auto (), X = Pos.Right (_sixelView), - Y = Pos.Bottom (_pxY) + 1, + Y = Pos.Bottom (_pxY) + 1 }; - _rgPaletteBuilder = new RadioGroup + _rgPaletteBuilder = new() { RadioLabels = new [] { "Popularity", - "Median Cut", + "Median Cut" }, X = Pos.Right (_sixelView) + 2, Y = Pos.Bottom (l1), @@ -401,21 +398,22 @@ private void BuildSixelTab () Value = 8 }; - var lblPopThreshold = new Label () + var lblPopThreshold = new Label { Text = "(threshold)", X = Pos.Right (_popularityThreshold), - Y = Pos.Top (_popularityThreshold), + Y = Pos.Top (_popularityThreshold) }; - var l2 = new Label () + var l2 = new Label { Text = "Color Distance Algorithm", Width = Dim.Auto (), X = Pos.Right (_sixelView), - Y = Pos.Bottom (_rgPaletteBuilder) + 1, + Y = Pos.Bottom (_rgPaletteBuilder) + 1 }; - _rgDistanceAlgorithm = new RadioGroup () + + _rgDistanceAlgorithm = new() { RadioLabels = new [] { @@ -423,7 +421,7 @@ private void BuildSixelTab () "CIE76" }, X = Pos.Right (_sixelView) + 2, - Y = Pos.Bottom (l2), + Y = Pos.Bottom (l2) }; _sixelSupported.Add (lblPxX); @@ -441,7 +439,7 @@ private void BuildSixelTab () _sixelView.DrawContent += SixelViewOnDrawContent; } - IPaletteBuilder GetPaletteBuilder () + private IPaletteBuilder GetPaletteBuilder () { switch (_rgPaletteBuilder.SelectedItem) { @@ -451,7 +449,7 @@ IPaletteBuilder GetPaletteBuilder () } } - IColorDistance GetDistanceAlgorithm () + private IColorDistance GetDistanceAlgorithm () { switch (_rgDistanceAlgorithm.SelectedItem) { @@ -466,6 +464,7 @@ private void OutputSixelButtonClick (object sender, CommandEventArgs e) if (_imageView.FullResImage == null) { MessageBox.Query ("No Image Loaded", "You must first open an image. Use the 'Open Image' button above.", "Ok"); + return; } @@ -493,9 +492,7 @@ private void OutputSixelButtonClick (object sender, CommandEventArgs e) _sixelImage.SixelData = _encodedSixelData; } - _sixelView.SetNeedsDisplay(); - - + _sixelView.SetNeedsDisplay (); } private void SixelViewOnDrawContent (object sender, DrawEventArgs e) diff --git a/UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs b/UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs index 7de04c652b..9a89c70c02 100644 --- a/UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs +++ b/UnitTests/Drawing/PopularityPaletteWithThresholdTests.cs @@ -25,7 +25,7 @@ public void BuildPalette_MaxColorsZero_ReturnsEmptyPalette () { // Arrange var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); - List colors = new() { new (255, 0), new (0, 255) }; + List colors = new () { new (255, 0), new (0, 255) }; // Act List result = paletteBuilder.BuildPalette (colors, 0); @@ -39,7 +39,7 @@ public void BuildPalette_SingleColorList_ReturnsSingleColor () { // Arrange var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); - List colors = new() { new (255, 0), new (255, 0) }; + List colors = new () { new (255, 0), new (255, 0) }; // Act List result = paletteBuilder.BuildPalette (colors, 256); @@ -55,7 +55,7 @@ public void BuildPalette_ThresholdMergesSimilarColors_WhenColorCountExceedsMax ( // Arrange var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); // Set merge threshold to 50 - List colors = new List + List colors = new() { new (255, 0), // Red new (250, 0), // Very close to Red @@ -78,7 +78,7 @@ public void BuildPalette_NoMergingIfColorCountIsWithinMax () // Arrange var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); - List colors = new() + List colors = new () { new (255, 0), // Red new (0, 255) // Green @@ -99,7 +99,7 @@ public void BuildPalette_MergesUntilMaxColorsReached () // Arrange var paletteBuilder = new PopularityPaletteWithThreshold (_colorDistance, 50); - List colors = new List + List colors = new() { new (255, 0), // Red new (254, 0), // Close to Red From 04b336a0ac0d860da4ae4171965f3dbe4035e0b3 Mon Sep 17 00:00:00 2001 From: tznind Date: Sun, 13 Oct 2024 20:27:41 +0100 Subject: [PATCH 60/62] Try adding sixel to curses driver --- Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs index 0b11949a94..9d7bd7b9c8 100644 --- a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs @@ -420,6 +420,13 @@ public override void UpdateScreen () } } + // SIXELS + foreach (var s in Application.Sixel) + { + SetCursorPosition (s.ScreenPosition.X, s.ScreenPosition.Y); + Console.Write(s.SixelData); + } + SetCursorPosition (0, 0); _currentCursorVisibility = savedVisibility; From 3156641c1e02c988260b929a3008691a5ea1ec47 Mon Sep 17 00:00:00 2001 From: tznind Date: Wed, 23 Oct 2024 19:30:35 +0100 Subject: [PATCH 61/62] Make sixel 'opt in' in images scenario and apply a hack to fix TabView not refreshing properly --- UICatalog/Scenarios/Images.cs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 13a552ee3a..e1cc7cac25 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -100,16 +100,27 @@ public override void Main () { X = Pos.Right (lblDriverName) + 2, Y = 1, - CheckedState = _sixelSupportResult.IsSupported - ? CheckState.Checked - : CheckState.UnChecked, + CheckedState = CheckState.UnChecked, Text = "Supports Sixel" }; + var lblSupportsSixel = new Label () + { + + X = Pos.Right (lblDriverName) + 2, + Y = Pos.Bottom (cbSupportsSixel), + Text = "(Check if your terminal supports Sixel)" + }; + + +/* CheckedState = _sixelSupportResult.IsSupported + ? CheckState.Checked + : CheckState.UnChecked;*/ cbSupportsSixel.CheckedStateChanging += (s, e) => { _sixelSupportResult.IsSupported = e.NewValue == CheckState.Checked; SetupSixelSupported (e.NewValue == CheckState.Checked); + ApplyShowTabViewHack (); }; _win.Add (cbSupportsSixel); @@ -130,7 +141,7 @@ public override void Main () _tabView = new () { - Y = Pos.Bottom (btnOpenImage), Width = Dim.Fill (), Height = Dim.Fill () + Y = Pos.Bottom (lblSupportsSixel), Width = Dim.Fill (), Height = Dim.Fill () }; _tabView.AddTab (tabBasic, true); @@ -143,6 +154,7 @@ public override void Main () btnOpenImage.Accepting += OpenImage; + _win.Add (lblSupportsSixel); _win.Add (_tabView); Application.Run (_win); _win.Dispose (); @@ -275,9 +287,19 @@ private void OpenImage (object sender, CommandEventArgs e) } _imageView.SetImage (img); + ApplyShowTabViewHack (); Application.Refresh (); } + private void ApplyShowTabViewHack () + { + // TODO HACK: This hack seems to be required to make tabview actually refresh itself + _tabView.SetNeedsDisplay(); + var orig = _tabView.SelectedTab; + _tabView.SelectedTab = _tabView.Tabs.Except (new []{orig}).ElementAt (0); + _tabView.SelectedTab = orig; + } + private void BuildBasicTab (Tab tabBasic) { _imageView = new () From ce41afdd1282981968a86c03a64a18149be4d8a4 Mon Sep 17 00:00:00 2001 From: tznind Date: Wed, 23 Oct 2024 19:36:09 +0100 Subject: [PATCH 62/62] xml comments --- Terminal.Gui/Application/Application.Driver.cs | 4 ++++ Terminal.Gui/Drawing/Quant/ColorQuantizer.cs | 6 ++++++ .../Drawing/Quant/PopularityPaletteWithThreshold.cs | 1 + 3 files changed, 11 insertions(+) diff --git a/Terminal.Gui/Application/Application.Driver.cs b/Terminal.Gui/Application/Application.Driver.cs index 0518dfdb5f..c2f6431db7 100644 --- a/Terminal.Gui/Application/Application.Driver.cs +++ b/Terminal.Gui/Application/Application.Driver.cs @@ -27,5 +27,9 @@ public static partial class Application // Driver abstractions [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] public static string ForceDriver { get; set; } = string.Empty; + /// + /// Collection of sixel images to write out to screen when updating. + /// Only add to this collection if you are sure terminal supports sixel format. + /// public static List Sixel = new List (); } diff --git a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs index 5aa10c652d..ec6d102ea8 100644 --- a/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs +++ b/Terminal.Gui/Drawing/Quant/ColorQuantizer.cs @@ -55,6 +55,12 @@ public void BuildPalette (Color [,] pixels) Palette = PaletteBuildingAlgorithm.BuildPalette (allColors, MaxColors); } + /// + /// Returns the closest color in that matches + /// based on the color comparison algorithm defined by + /// + /// + /// public int GetNearestColor (Color toTranslate) { if (_nearestColorCache.TryGetValue (toTranslate, out int cachedAnswer)) diff --git a/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs index 0a7e8a0234..86bbed404d 100644 --- a/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs +++ b/Terminal.Gui/Drawing/Quant/PopularityPaletteWithThreshold.cs @@ -23,6 +23,7 @@ public PopularityPaletteWithThreshold (IColorDistance colorDistance, double merg _mergeThreshold = mergeThreshold; // Set the threshold for merging similar colors } + /// public List BuildPalette (List colors, int maxColors) { if (colors == null || colors.Count == 0 || maxColors <= 0)