Home > Enterprise >  Displaying ASCII Art In WPF TextBlock Has Strange Line Breaks
Displaying ASCII Art In WPF TextBlock Has Strange Line Breaks

Time:03-04

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

the ASCII Text in TextBlock

ASCII In Output Log

the ASCII Text in Output Window

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

enter image description here

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 the TextBlock. using <TextBlock>'s default FontFamily 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 to 10.
<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:

enter image description here

With Courier New:

enter image description here

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[] { ' ', '░', '▒', '▓', '█' };

enter image description here


Problem 2: Performance

Use Bitmap.Lockbits:

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 a Char to a String with .ToString(): that's just silly.
      • Using StringBuilder.Append(Char) is very fast because it just copies the scalar char value directly into StringBuilder's internal char buffer.
      • String objects exist on the heap which requires allocation and copying, so they're relatively expensive compared to using char values, which don't need to use the heap at all until/unless boxed.
    • Additionally, preallocating the StringBuilder by setting capacity: width * height means that the StringBuilder 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's capacity: you should do that.

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 a RELEASE build for some reason.
  • I used Stopwatch to measure the time taken to convert the already-loaded Bitmap to a String - 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 500x500px Bitmap 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.

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:

  • Related