I made a simple app in C# WPF that displays ascii art from an image, the code seems to work but the problem is that whenever I set the text of the textblock to the ascii art it seems to have strange line breaks that have nothing to do with my code
ASCII In Textblock
ASCII In Output Log
Here you can see that the output log displays the art correctly while the textblock has line breaks, also I experimented with TextBox and RichTextBox and they gave me the same result
Image To Ascii Art Code
void TurnImageToAscii(Bitmap image)
{
StringBuilder sb = new StringBuilder();
for (int j = 0; j < image.Height; j )
{
for (int i = 0; i < image.Width; i )
{
Color pixelColor = image.GetPixel(i,j);
int brightness = (pixelColor.R pixelColor.G pixelColor.B) / 3;
int charIndex = brightness.Remap(0, 255, 0, chars.Length - 1);
string c = chars[charIndex].ToString();
sb.Append(c);
}
sb.Append("\n");
}
asciiTextBlock.Text = sb.ToString();
}
How can I fix this problem?
Edit: Remap Function
public static class ExtensionMethods
{
public static int Remap(this int value, int from1, int to1, int from2, int to2)
{
return (value - from1) / (to1 - from1) * (to2 - from2) from2;
}
}
The chars string:
string chars = " .:-^$@";
this is where the chars array is declared, and it has only been refrenced once in the TurnImageToAscii
function that I posted in the question above
XAML
<Window x:Class="AsciiImageWPF.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:AsciiImageWPF"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid Loaded="Grid_Loaded">
<ComboBox x:Name="videoDevicesList" HorizontalAlignment="Left" Margin="24,22,0,0" VerticalAlignment="Top" Width="419"/>
<Button x:Name="start_btn" Content="Start" HorizontalAlignment="Left" Margin="448,22,0,0" VerticalAlignment="Top" Height="22" Width="77" Click="btn_start_Click"/>
<Image x:Name="videoImage" HorizontalAlignment="Left" Height="362" Margin="10,58,0,0" VerticalAlignment="Top" Width="388" Stretch="Fill"/>
<TextBlock x:Name="asciiTextBlock" HorizontalAlignment="Left" Margin="410,58,0,0" VerticalAlignment="Top" Height="366" Width="380" FontSize="3"/>
</Grid>
Image Used
CodePudding user response:
Problem 1: ASCII art not being displayed properly in WPF TextBlock
:
- ASCII-Art needs to be rendered with a fixed-width (aka monospaced) font.
- Your posted XAML shows you're not setting
FontFamily
on theTextBlock
. using<TextBlock>
's defaultFontFamily
which will be Segoe UI.- Segoe UI is not a monospaced font.
- The problem isn't extra line-breaks being added, but that the rendered lines are being visually compressed.
- Change the your XAML to this and it will work as-expected:
- I've also increased the font-size from
3
to10
.
- I've also increased the font-size from
<TextBlock
x:Name= "asciiTextBlock "
HorizontalAlignment= "Left "
Margin= "410,58,0,0 "
VerticalAlignment= "Top "
Height= "366 "
Width= "380 "
FontSize= "10 "
FontFamily= "Courier New "
/>
Screenshot proof:
With the default font:
With Courier New
:
I also made a version that uses Unicode block chars, just to make sure I was decoding the image correctly:
static readonly Char[] _chars = new[] { ' ', '░', '▒', '▓', '█' };
Problem 2: Performance
Use Bitmap.Lockbits
:
- You can significantly increase performance by using
Bitmap.Lockbits
instead of iterating over.GetPixel()
.GetPixel()
is really slow: https://www.codeproject.com/Articles/406045/Why-the-use-of-GetPixel-and-SetPixel-is-so-ineffic
Something like this:
unsafe static String RenderPixelsAsAscii_LockBits( FileInfo imageFile )
{
using( Bitmap bitmap = (Bitmap)System.Drawing.Image.FromFile( imageFile.FullName ) )
{
Rectangle r = new Rectangle( x: 0, y: 0, width: bitmap.Width, height: bitmap.Height );
BitmapData bitmapData = bitmap.LockBits( rect: r, flags: ImageLockMode.ReadOnly, bitmap.PixelFormat );
try
{
StringBuilder sb = new StringBuilder( capacity: bitmapData.Width * bitmapData.Height );
Int32 bytesPerPixel = System.Drawing.Image.GetPixelFormatSize( bitmapData.PixelFormat ) / 8;
Byte* scan0 = (Byte*)bitmapData.Scan0;
for( Int32 y = 0; y < bitmapData.Height; y )
{
Byte* linePtr = scan0 ( y * bitmapData.Stride );
for( Int32 x = 0; x < bitmapData.Width; x )
{
Byte* pixelPtr = linePtr ( x * bytesPerPixel );
UInt32 pixel = *pixelPtr;
sb.AppendPixel( pixel, bitmapData.PixelFormat );
}
sb.Append( '\n' );
}
return sb.ToString();
}
finally
{
bitmap.UnlockBits( bitmapData );
}
}
}
public static void AppendPixel( this StringBuilder sb, UInt32 pixel, PixelFormat fmt )
{
Byte a;
Byte r;
Byte g;
Byte b;
switch( fmt )
{
case PixelFormat.Format24bppRgb:
{
r = (Byte)( ( pixel & 0xFF_00_00_00 ) >> 32 );
g = (Byte)( ( pixel & 0x00_FF_00_00 ) >> 24 );
b = (Byte)( ( pixel & 0x00_00_FF_00 ) >> 16 );
}
break;
case PixelFormat.Format32bppArgb:
{
a = (Byte)( ( pixel & 0xFF_00_00_00 ) >> 24 );
r = (Byte)( ( pixel & 0x00_FF_00_00 ) >> 16 );
g = (Byte)( ( pixel & 0x00_00_FF_00 ) >> 8 );
b = (Byte)( ( pixel & 0x00_00_00_FF ) >> 0 );
}
break;
case PixelFormat.etc...:
// TODO if needed.
default:
throw new NotSupportedException( "meh" );
}
Single avgBrightness = ( (Single)r (Single)g (Single)b ) / 3f;
if ( avgBrightness < 51 ) sb.Append( '█' );
else if( avgBrightness < 102 ) sb.Append( '▓' );
else if( avgBrightness < 153 ) sb.Append( '▒' );
else if( avgBrightness < 204 ) sb.Append( '░' );
else sb.Append( ' ' );
}
- Your code can also be modified to be more efficient with
StringBuilder
:- Append
char
values directly instead of converting aChar
to aString
with.ToString()
: that's just silly.- Using
StringBuilder.Append(Char)
is very fast because it just copies the scalarchar
value directly intoStringBuilder
's internal char buffer. String
objects exist on the heap which requires allocation and copying, so they're relatively expensive compared to usingchar
values, which don't need to use the heap at all until/unless boxed.
- Using
- Additionally, preallocating the
StringBuilder
by settingcapacity: width * height
means that theStringBuilder
won't need to reallocate and copy its internal buffer every time it overflows.- The default capacity of a
StringBuilder
is only 16 chars, so if you can precompute an upperbound for the final length of a StringBuilder for its constructor'scapacity:
you should do that.
- The default capacity of a
- Append
Here's the runtime performance figures I get:
- .NET 6 numbers from running in Linqpad 7 (.NET 6) x64 on a PC with a i7-10700K CPU running Windows 10 20H2 x64.
- .NET 4.8 numbers from running in Linqpad 5's AnyCPU build.
- I benchmarked both a
DEBUG
and aRELEASE
build for some reason. - I used
Stopwatch
to measure the time taken to convert the already-loadedBitmap
to aString
- so it doesn't include the time to load the image from disk, nor the time for WPF to render the text, as that's unrelated to my suggested improvements). - The numbers shown are the best of 3 runs after an initial warmup run.
.NET 6 (RELEASE, x64) | .NET 4.8 (RELEASE, x64) | .NET 6 (DEBUG, x64) | .NET 4.8 (DEBUG, x64) | |
---|---|---|---|---|
Your original converter function | 1.41ms | 1.87ms | 2.52ms | 2.90ms |
Your original converter function, but with improved StringBuilder usage | 1.37ms | 1.76ms | 2.41ms | 2.85ms |
Using BitmapSource.CopyPixels |
0.31ms | 0.26ms | ||
Using LockBits instead |
0.04ms | 0.04ms | 0.43ms | 0.13ms |
Relative performance improvement of LockBits compared to GetPixel |
~35x | ~46x | ~6x | ~22x |
- The
0.04ms
figure is not a typo. It really is that fast. - The improved
StringBuilder
usage does help, but I'll agree that the sub-millisecond times shaved off really isn't significant. - I appreciate that for this project on modern hardware, even the worst-case of 2.90ms isn't bad for the
GetPixel
approach but I was really surprised at how fast the "slow" approach is now....- ...compared to about 17 years ago when I was first learning .NET and wanting to use
System.Drawing
to create a dynamic image-macro1 generator for my website and attempting to read even a 500x500pxBitmap
on my then single-core Pentium 4 1.9Ghz (not even Hyper-Threading) took at least a few seconds, which led me to seek-out a faster way, after all, even my old Pentium 166 could process 640x480-sized bitmap images from ancient video file formats in real-time, so I assumed I was doing something wrong.
- ...compared to about 17 years ago when I was first learning .NET and wanting to use
Here's my code, you should be able to copy paste this into Linqpad or a new blank C# project:
const String XAML_TEXT = @"
<Window
xmlns =""http://schemas.microsoft.com/winfx/2006/xaml/presentation""
xmlns:x =""http://schemas.microsoft.com/winfx/2006/xaml""
xmlns:mc =""http://schemas.openxmlformats.org/markup-compatibility/2006""
xmlns:local =""clr-namespace:AsciiImageWPF""
Title=""MainWindow""
Width = ""1024""
Height = ""1024""
>
<Grid ShowGridLines=""True"">
<Grid.ColumnDefinitions>
<ColumnDefinition></ColumnDefinition>
<ColumnDefinition></ColumnDefinition>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
</Grid.RowDefinitions>
<TextBlock
Grid.Row=""0""
Grid.Column=""0""
Text=""Variable-width font, your chars""
/>
<TextBlock
Grid.Row=""0""
Grid.Column=""1""
Text=""Variable-width font, block chars""
/>
<TextBlock
Grid.Row=""1""
Grid.Column=""0""
Text=""Monospace font, your chars""
/>
<TextBlock
Grid.Row=""1""
Grid.Column=""1""
Text=""Monospace font, block chars""
/>
<!-- ############################# -->
<TextBlock
x:Name=""asciiTextBlockVariableSlow""
Grid.Row=""0""
Grid.Column=""0""
Margin=""0,20,0,0""
FontSize=""8""
Background=""#f2fff2""
/>
<TextBlock
x:Name=""asciiTextBlockVariableFast""
Grid.Row=""0""
Grid.Column=""1""
Margin=""0,20,0,0""
FontSize=""8""
Background=""#ffeeed""
/>
<TextBlock
x:Name=""asciiTextBlockMonospaceSlow""
Grid.Row=""1""
Grid.Column=""0""
Margin=""0,20,0,0""
FontSize=""8""
Background=""#f2f7ff""
FontFamily=""Consolas""
/>
<TextBlock
x:Name=""asciiTextBlockMonospaceFast""
Grid.Row=""1""
Grid.Column=""1""
Margin=""0,20,0,0""
FontSize=""8""
Background=""#fffff2""
FontFamily=""Consolas""
/>
</Grid>
</Window>
";
const String IMAGE_PATH = @"C:\Users\YOU\Downloads\2022-03\xfqJC.png";
async Task Main()
{
StringReader stringReader = new StringReader( XAML_TEXT );
XmlReader xmlReader = XmlReader.Create( stringReader );
Window window = (Window)XamlReader.Load( xmlReader );
window.Show();
///////
FileInfo imageFile = new FileInfo( IMAGE_PATH );
String orig = RenderPixelsAsAscii_Orig( imageFile );
String orig2 = RenderPixelsAsAscii_Orig_better_StringBuilder( imageFile );
String bmpSrc = RenderPixelsAsAscii_BitmapSource( imageFile );
String mine = RenderPixelsAsAscii_LockBits( imageFile );
//
TextBlock asciiTextBlockVariableSlow = (TextBlock)window.FindName( name: "asciiTextBlockVariableSlow" );
TextBlock asciiTextBlockVariableFast = (TextBlock)window.FindName( name: "asciiTextBlockVariableFast" );
TextBlock asciiTextBlockMonospaceSlow = (TextBlock)window.FindName( name: "asciiTextBlockMonospaceSlow" );
TextBlock asciiTextBlockMonospaceFast = (TextBlock)window.FindName( name: "asciiTextBlockMonospaceFast" );
window.Dispatcher.Invoke( () => {
asciiTextBlockVariableSlow.Text = orig;
asciiTextBlockVariableFast.Text = mine;
asciiTextBlockMonospaceSlow.Text = orig;
asciiTextBlockMonospaceFast.Text = mine;
} );
}
static string chars = " .:-^$@";
static String RenderPixelsAsAscii_Orig( FileInfo imageFile )
{
Stopwatch sw = Stopwatch.StartNew();
using( Bitmap image = (Bitmap)System.Drawing.Image.FromFile( imageFile.FullName ) )
{
// sw.Elapsed.TotalMilliseconds.Dump( "RenderPixelsAsAscii_Orig: Time to load image from disk." );
sw.Restart();
StringBuilder sb = new StringBuilder();
for (int j = 0; j < image.Height; j )
{
for (int i = 0; i < image.Width; i )
{
Color pixelColor = image.GetPixel(i,j);
int brightness = (pixelColor.R pixelColor.G pixelColor.B) / 3;
int charIndex = brightness.Remap(0, 255, 0, chars.Length - 1);
string c = chars[charIndex].ToString();
sb.Append(c);
}
sb.Append("\n");
}
sw.Elapsed.TotalMilliseconds.Dump( "RenderPixelsAsAscii_Orig: Time to render bitmap as ASCII." );
//asciiTextBlock.Text = sb.ToString();
return sb.ToString();
}
}
static String RenderPixelsAsAscii_Orig_better_StringBuilder( FileInfo imageFile )
{
Stopwatch sw = Stopwatch.StartNew();
using( Bitmap image = (Bitmap)System.Drawing.Image.FromFile( imageFile.FullName ) )
{
// sw.Elapsed.TotalMilliseconds.Dump( "RenderPixelsAsAscii_Orig_better_StringBuilder: Time to load image from disk." );
sw.Restart();
StringBuilder sb = new StringBuilder( capacity: image.Width * image.Height );
for (int j = 0; j < image.Height; j )
{
for (int i = 0; i < image.Width; i )
{
Color pixelColor = image.GetPixel(i,j);
int brightness = (pixelColor.R pixelColor.G pixelColor.B) / 3;
int charIndex = brightness.Remap(0, 255, 0, chars.Length - 1);
sb.Append(chars[charIndex]);
}
sb.Append('\n');
}
sw.Elapsed.TotalMilliseconds.Dump( "RenderPixelsAsAscii_Orig_better_StringBuilder: Time to render bitmap as ASCII." );
//asciiTextBlock.Text = sb.ToString();
return sb.ToString();
}
}
unsafe static String RenderPixelsAsAscii_LockBits( FileInfo imageFile )
{
Stopwatch sw = Stopwatch.StartNew();
using( Bitmap bitmap = (Bitmap)System.Drawing.Image.FromFile( imageFile.FullName ) )
{
// sw.Elapsed.TotalMilliseconds.Dump( "RenderPixelsAsAscii_LockBits: Time to load image from disk." );
sw.Restart();
Rectangle r = new Rectangle( x: 0, y: 0, width: bitmap.Width, height: bitmap.Height );
BitmapData bitmapData = bitmap.LockBits( rect: r, flags: ImageLockMode.ReadOnly, bitmap.PixelFormat );
try
{
StringBuilder sb = new StringBuilder( capacity: bitmapData.Width * bitmapData.Height );
RenderPixelsAsAsciiInner( bitmapData, sb );
sw.Elapsed.TotalMilliseconds.Dump( "RenderPixelsAsAscii_LockBits: Time to render bitmap as ASCII." );
return sb.ToString();
}
finally
{
bitmap.UnlockBits( bitmapData );
}
}
}
unsafe static void RenderPixelsAsAsciiInner( BitmapData bitmapData, StringBuilder sb )
{
// bpp == Length of each pixel in bytes. e.g. 24-bit RGB is 3, and 32-bit ARGB is 4.
Int32 bitsPerPixel = System.Drawing.Image.GetPixelFormatSize( bitmapData.PixelFormat );
if( ( bitsPerPixel % 8 ) != 0 ) throw new NotSupportedException( "Image uses a non-integral-pixel-byte-width format: " bitmapData.PixelFormat );
Int32 bytesPerPixel = bitsPerPixel / 8;
Byte* scan0 = (Byte*)bitmapData.Scan0;
for( Int32 y = 0; y < bitmapData.Height; y )
{
Byte* linePtr = scan0 ( y * bitmapData.Stride );
for( Int32 x = 0; x < bitmapData.Width; x )
{
Byte* pixelPtr = linePtr ( x * bytesPerPixel );
UInt32 pixel = *pixelPtr;
sb.AppendPixel( pixel, bitmapData.PixelFormat );
}
sb.Append( '\n' );
}
}
static String RenderPixelsAsAscii_BitmapSource( FileInfo imageFile )
{
Stopwatch sw = Stopwatch.StartNew();
BitmapImage bmpSrc = new BitmapImage( new Uri( imageFile.FullName ) ); // `class BitmapImage : BitmapSource` btw.
if( ( bmpSrc.Format.BitsPerPixel % 8 ) != 0 ) throw new NotSupportedException( "Image uses a non-integral-pixel-byte-width format: " bmpSrc.Format.ToString() );
sw.Elapsed.TotalMilliseconds.Dump( "RenderPixelsAsAscii_BitmapSource: Time to load image from disk." );
sw.Restart();
Int32 w = (Int32)bmpSrc.PixelWidth; // <-- WARNING: It's a common gotcha to use `BitmapImage.Width` instead of `BitmapImage.PixelWidth`. *DO NOT USE* `BitmapImage.Width` as that's `double`, not `int`, in "Device Dependent Units" *not* pixels!
Int32 h = (Int32)bmpSrc.PixelHeight;
Int32 bytesPerPixel = bmpSrc.Format.BitsPerPixel / 8;
Int32 stride = w * bytesPerPixel; // Why on earth doesn't BitmapSource do this calculation for us like System.Drawing does?
Int32 bufferLengthBytes = h * stride;
PixelFormat pf = bmpSrc.Format.ToGdiPixelFormat();
Byte[] buffer = new Byte[ bufferLengthBytes ];
bmpSrc.CopyPixels( pixels: buffer, stride: stride, offset: 0 );
StringBuilder sb = new StringBuilder( capacity: (Int32)bmpSrc.Width * (Int32)bmpSrc.Height );
for( Int32 y = 0; y < h; y )
{
for( Int32 x = 0; x < w; x )
{
Int32 pixelIdx = ( y * stride ) ( x * bytesPerPixel );
UInt32 pixel = BitConverter.ToUInt32( buffer, startIndex: pixelIdx );
sb.AppendPixel( pixel, pf );
}
sb.Append( '\n' );
}
sw.Elapsed.TotalMilliseconds.Dump( "RenderPixelsAsAscii_BitmapSource: Time to render bitmap as ASCII." );
return sb.ToString();
}
static class MyExtensions
{
public static PixelFormat ToGdiPixelFormat( this System.Windows.Media.PixelFormat wpfPixelFormat )
{
if( wpfPixelFormat.Equals( System.Windows.Media.PixelFormats.Bgra32 ) )
{
return PixelFormat.Format32bppArgb;
}
else
{
throw new NotSupportedException( "TODO" );
}
}
static readonly Char[] _chars = new[] { ' ', '░', '▒', '▓', '█' }; // " .:-^$@";
public static void AppendPixel( this StringBuilder sb, UInt32 pixel, PixelFormat fmt )
{
Byte a;
Byte r;
Byte g;
Byte b;
switch( fmt )
{
case PixelFormat.Format24bppRgb:
{
r = (Byte)( ( pixel & 0xFF_00_00_00 ) >> 24 );
g = (Byte)( ( pixel & 0x00_FF_00_00 ) >> 16 );
b = (Byte)( ( pixel & 0x00_00_FF_00 ) >> 8 );
}
break;
case PixelFormat.Format32bppArgb:
{
a = (Byte)( ( pixel & 0xFF_00_00_00 ) >> 24 );
r = (Byte)( ( pixel & 0x00_FF_00_00 ) >> 16 );
g = (Byte)( ( pixel & 0x00_00_FF_00 ) >> 8 );
b = (Byte)( ( pixel & 0x00_00_00_FF ) >> 0 );
}
break;
default:
throw new NotSupportedException( "meh" );
}
AppendPixel( sb, r: r, g: g, b: b );
}
public static void AppendPixel( this StringBuilder sb, Byte r, Byte g, Byte b )
{
Single avgBrightness = ( (Single)r (Single)g (Single)b ) / 3f;
if ( avgBrightness < 51 ) sb.Append( '█' );
else if( avgBrightness < 102 ) sb.Append( '▓' );
else if( avgBrightness < 153 ) sb.Append( '▒' );
else if( avgBrightness < 204 ) sb.Append( '░' );
else sb.Append( ' ' );
}
public static int Remap( this int value, int from1, int to1, int from2, int to2)
{
return (value - from1) / (to1 - from1) * (to2 - from2) from2;
}
}
1 Now they're just called memes. This was before Facebook, before the time when the Internet went mainstream, now it's just lame yaknow?
Screenshot proof: