Mit Sixel dynamisch Bitmaps erstellen und in die .NET-Konsole rendern
Retro-Hardware mit .NET in Software nachbilden, Teil 3
Eine wachsende Zahl von Tools, auch die der großen AI-Anbieter, nutzen die Konsole als UI. Sei es für die Ausgabe von Logs, die Darstellung von Statistiken oder auch für interaktive Eingaben. Die Konsolenanwendung ist dabei meist plattformübergreifend verfügbar und lässt sich einfach in bestehende Workflows einbinden.
Sie folgt bei der Ausgabe einem Jahrzehnte alten Standard aus den 1980ern, dem ANSI/VT100-Standard. Dieser definiert bestimmte Escape-Sequenzen, also aus purem Text bestehende Steueranweisungen, die das ANSI-kompatible Terminal, welches die Konsole anzeigt, aus dem Ausgangszeichenstrom herausfiltert und interpretiert. Dadurch wird es der Anwendung möglich, seinen Text zu formatieren, Farben zu setzen oder auch den Cursor zu bewegen.
Für die meisten Anwendungsfälle reichen diese Möglichkeiten und eine etwas mit Unicodezeichen angereicherte ASCII-Art als UI vollkommen aus. Was aber, wenn man mehr als nur Text darstellen möchte, nämlich echte Grafiken? In diesem Falle steht man davor, die Konsole zu verlassen und ein UI-Framework wie WinUI oder Uno einzubinden – zusammen mit einer umfangreichen Abhängigkeitskette. Oder doch nicht?
In diesem Artikel wollen wir uns anschauen, wie wir mit Sixel Bitmap-Grafiken direkt in die Konsole ausgeben können. Nötig ist hierfür nur ein Terminal, das die Sixel-spezifischen ANSI-Escape-Sequenzen versteht und in Bitmap-Grafik umwandelt. Viele der modernen Terminals, darunter auch das Windows Terminal und xterm, unterstützen Sixel und bieten damit eine plattformübergreifende Möglichkeit, Bitmap-Grafiken im Terminal auszugeben. Unter Are We Sixel Yet? lässt sich einsehen, welche weiteren Terminals geeignet sind.
Der Sixel-Zeichenstrom
Um Bitmap-Grafiken mit Sixel auszugeben, ist es erforderlich, einen speziellen Zeichenstrom an das Terminal zu senden. In C# reicht dafür eine Formatierung des Zeichenstroms in einen string und seine anschließende Ausgabe durch Console.Write(string) aus.
Das Kernkonzept von Sixel ergibt sich bereits aus dem Namen „Sixel“, der ausgeschrieben für „Six Pixels“, übersetzt also für „Sechs Pixel“, steht. Sixel-Grafiken setzen sich aus beliebig vielen, jeweils 6 Pixel hohen Bändern zusammen, die ihrerseits aus einer Aneinanderreihung von 1 Pixel breiten Spalten bestehen. Wer sich dabei an einen alten Nadeldrucker erinnert fühlt, liegt damit gar nicht so falsch.
Die Daten einer einzelnen Spalte im Band werden durch ein entsprechendes Textzeichen repräsentiert, das bitweise den Zustand seiner sechs vertikal übereinander liegenden Pixel codiert, beginnend mit dem obersten Pixel im niederwertigsten Bit. Ein gesetztes Bit zeichnet den entsprechenden Pixel, ein gelöschtes Bit steht für Transparenz, also keine Ausgabe. Welchen Farbwert das Bit 1 zeichnet, wird separat über ein Farbregister definiert.
Überführt man diese sechs Bits in eine vorzeichenlose Zahl, ergibt sich daraus zunächst eine Zahl zwischen 0 und 63. Damit sich diese als ein ASCII-Zeichen abbilden lässt, ohne dabei Steuerzeichen einzubeziehen, wird ein Versatz von 63 addiert. Eine 0 wird damit zur Zahl 63, was in der ASCII-Tabelle dem Fragezeichen (?) entspricht. Die 63 wiederum ergibt die Zahl 126, was in der ASCII-Tabelle der Tilde (~) entspricht. Alle Zeichen dazwischen sind ebenfalls reguläre Textzeichen.
Schauen wir uns das an einem konkreten Beispiel an, in dem wir mit der Farbe Weiß eine einzelne Sixel-Spalte, die alternierend Pixel mit dem Bitwert 1 (weiß) und 0 (transparent) enthält, auf einen schwarzen Hintergrund zeichnen (Tabelle 1).
Tabelle 1: Bitmuster-Konvertierung für das Sixel-Zeichen „T“
| Binär | Hexadezimal | Dezimal | Zeichen |
|---|---|---|---|
| 010101 | 15 | 21 | T |
Addieren wir auf den Dezimalwert 21 die Zahl 63, ergibt das 84; in der ASCII-Tabelle entspricht das einem T.
Weisen wir Sixel nun an, den Buchstaben T als Sixel zu rendern, erhalten wir die gewünschte und in Bild 1 gezeigte Darstellung.
Sixel-Band (Bild 1)
AutorSixel ausgeben
Sixel kann man sich ähnlich wie ein HTML-Tag mit Inhalt vorstellen, also eine Zeichenfolge mit einem Start- und einem End-Marker. Um die Sixel-Steuersequenz einzuleiten, reicht das Steuerzeichen ESC (Hex: 1B | Dez: 27) gefolgt von den Zeichen Pq. Zum Abschließen der Sequenz wird erneut das Steuerzeichen ESC gesendet, gefolgt vom Zeichen \. Ist die Sixel-Steuersequenz beendet, verarbeitet das Terminal alle Befehle wieder wie gewohnt.
Bei der Einleitung der Sixel-Steuersequenz können drei optionale Parameter mitgegeben werden, die zwischen den beiden Zeichen Pq eingesetzt werden. Mit diesen lassen sich Einstellungen wie das Seitenverhältnis, Transparenz oder Rastergröße kontrollieren. In der Praxis hat sich etabliert, hier nur den Parameter für Transparenz anzugeben, da Seitenverhältnis und Bildgröße üblicherweise über das Raster-Attribut definiert werden.
Größe des Bitmaps setzen
Mit dem Zeichen " lassen sich Raster-Attribute definieren, die das Seitenverhältnis und die Größe des Bitmaps festlegen. Meist folgen die Raster-Attribute direkt nach der Einleitung der Sixel-Steuersequenz. Die Raster-Attribute bestehen aus vier Werten, getrennt durch ein Semikolon (;), wobei die ersten beiden Zahlen das Seitenverhältnis festlegen, die beiden danach die Auflösung. Ein Bitmap mit dem Seitenverhältnis 1:1 und einer Größe von 800x600 Pixeln wird entsprechend mit folgender Sequenz definiert: "1;1;800;600.
Wer sich die Definition des Seitenverhältnisses anschaut, wird sich wundern, weshalb bei einer Größe von 800x600 ein Seitenverhältnis von 1:1 angegeben wird anstatt 4:3. Das Seitenverhältnis ist bei Sixel eine Bruchrechnung, deren Ergebnis durch einen Faktor bestimmt, wie oft eine Bildzeile gezeichnet wird. Bei "1;1 wird jede Zeile einmal gezeichnet, bei "2;1 zweimal. "1;2 produziert das gleiche Ergebnis wie "1;1, da die Bruchrechnung 0,5 ergeben würde und es keine halben Zeilen gibt.
Farben
Farbpaletten werden durch das Zeichen # eingeleitet. Hierbei folgt dem # die Nummer eines Farbregisters, gefolgt von weiteren Argumenten, je nachdem, ob die Farbe definiert oder angewendet werden soll.
Zur Definition der Farbe wird die Syntax #<Nummer>;2;<R>;<G>;<B> benutzt. R, G und B stehen für die Farbwerte Rot, Grün und Blau. Im Gegensatz zur heute allgemeinen Definition eines Farbwerts als Byte zwischen 0 und 255 sind die Werte bei Sixel in Prozent angegeben. Mit #2;2;100;0;0 würde man der Farbpalette 2 einen Farbwert zuweisen, der zu 100 Prozent aus Rot besteht und keinerlei grüne oder blaue Farbanteile beinhaltet.
Zur Verwendung reicht die Syntax #<Nummer>;, danach können direkt die weiter oben unter „Der Sixel-Zeichenstrom“ erläuterten sechs Pixel hohen Bänder angegeben werden. Um auf das Beispiel mit den abwechselnden Farben zurückzukommen: Wir können durch den einfachen Befehl #2;TiTiTi ein 6x6 Pixel großes Schachbrett-Muster zeichnen, bei dem jedes aktive Bit den Farbwert aus Farbpalette 2 setzt.
Zeilensprünge und Kompression
Um auch mehrfarbige Grafiken und Fotos abbilden zu können, bietet Sixel am Ende einer Zeile die Möglichkeit, mit $ wieder an den Anfang der Zeile zurückzuspringen und diese anschließend mit anderen Farbwerten zu „überpinseln“. Ein - dagegen schließt die Zeile ab und wechselt in die nächste. Bei der Definition einer Zeile spielen wieder die „6 Pixel“ eine Rolle: Jede Zeile ist sechs Pixel hoch.
Abschließend unterstützt Sixel auch eine rudimentäre Form von Kompression, mit der sich wiederholende Bänder markieren lassen. Die Abfolgen beginnen mit einem !, gefolgt von der Nummer der Wiederholungen. Direkt danach folgt der Wert des unter „Der Sixel-Zeichenstrom“ erläuterten Bandes. Mit !8T erhalten wir entsprechend die aus den vorigen Beispielen bekannten abwechselnd gesetzten Pixel – nur 8-mal nebeneinander.
Beispiel 1: Statische 800x600-Bitmap-Grafik
Wie kompakt sich die Darstellung einer 800x600 großen Bitmap-Grafik ausdrücken lässt, zeigt das folgende Codebeispiel:
// Sixel-Modus aktivieren, eine Größe von 800x600 setzen, Farben definieren (RGB in Prozent)
// Farbpalette 0 definiert blau (Himmel), 1 gelb (Wände), 2 rot (Dach) und 3 braun (Tür)
var s = "\x1bPq\"1;1;800;600#0;2;53;81;92#1;2;100;96;62#2;2;80;12;12#3;2;47;28;16";
// Wir gehen die 600 Pixel hohe Bitmap-Grafik Sixel-Band für Sixel-Band durch (jeweils 6 Pixel hoch)
for (int y = 0; y < 600; y += 6)
{
s += "#0!800~$"; // Himmel
if (y >= 120 && y < 252) { int w = (y - 114) * 4 / 3; s += $"#2!{400 - w}?!{2 * w}~$"; } // Dach
if (y >= 252 && y < 450) s += "#1!250?!300~$"; // Wände
if (y >= 342 && y < 450) s += "#3!370?!60~$"; // Tür
s += "-";
}
Console.WriteLine(s + "\x1b\\"); // Sixel verlassen
Es reicht, den Quellcode des Beispiels oben in eine Datei mit dem Namen simplehouse.cs zu kopieren und diese dann mit dotnet run simplehouse.cs auszuführen. Bild 2 zeigt das Ergebnis.
Pixel-Haus in Sixel (Bild 2)
AutorBeispiel 2: Animierte Bilder
Wir kamen auf Sixel, als wir während des Schreibens eines Game-Boy-Emulators [1] an den Punkt kamen, an dem wir unsere Implementierung der Grafiklogik testen wollten.
Im Vergleich zur bisher hier beschriebenen Ausgabe einer einzelnen Bitmap-Grafik benötigen wir hierfür jedoch eine konstante Ausgabe von Sixel-Grafiken – und das etwa 60-mal pro Sekunde. Dazu rufen wir im Fall unseres Game-Boy-Emulators für jedes Bild, das gerendert werden soll, einmal folgende Methode auf:
private static void PrintFrameBufferAsSixel(byte[,] frameBuffer)
{
// Wir skalieren das Bild um einen Faktor von 4 ausgehend der Auflösung des Game Boy
var scale = 4;
var width = 160 * scale;
var height = 144 * scale;
var sb = new StringBuilder();
sb.Append($"\eP0;0;0q\"1;1;{width};{height}");
sb.Append("#0;2;100;100;100#1;2;66;66;66#2;2;33;33;33#3;2;0;0;0");
for (int y = 0; y < height; y += 6)
{
for (int color = 0; color < 4; color++)
{
sb.Append('#').Append(color);
for (int x = 0; x < width; x++)
{
var c = (char)0;
for (int subY = 0; subY < 6; subY++)
{
var pixelColor = frameBuffer[x / scale, (y + subY) / scale];
if (pixelColor == color)
c |= (char)(1 << subY);
}
c += '?';
sb.Append(c);
}
sb.Append(color == 3 ? '-' : '$');
}
}
sb.Append("\e\\");
System.Console.WriteLine(sb.ToString());
}
Das Argument frameBuffer ist hierbei ein zweidimensionales Byte-Array, das die Pixelwerte des Bildes enthält. Die erste Dimension im Array enthält die Farbwerte für eine Zeile, die zweite Dimension die für die Spalte. Der eigentliche Byte-Wert enthält eine der vier Farben des Game Boy.
Das Ergebnis ist ein vollständig spielbarer Game-Boy-Emulator im Terminal (Bild 3).
Game-Boy-Emulation in Sixel (Bild 3)
AutorFazit
Obwohl der Sixel-Standard bereits in den 1980ern entwickelt und ursprünglich vorwiegend für das Drucken auf Matrixdruckern sowie zur Ausgabe von Bitmapdaten auf Terminal-Computern eingesetzt wurde, kann er auch heute noch in den meisten Terminals auf modernen Computern verwendet werden. Sixel ersetzt dabei keine nativen UI-Frameworks und läuft immer dann in ein Limit, wenn wir skalieren oder komplexere Inhalte flüssig darstellen wollen. Dennoch ermöglicht es uns, in der Konsole auf einfache Weise Bilder und kleinere Visualisierungen darzustellen – portabel und ohne weitere Abhängigkeiten.
[1] Stefan Fiedler, Batteries not included, dotnetpro 4/2026, Seite 112 ff.