在 WPF TextBlock 中显示 ASCII 艺术有奇怪的换行符

Displaying ASCII Art In WPF TextBlock Has Strange Line Breaks

我用 C# WPF 制作了一个简单的应用程序,它显示图像的 ascii 艺术,代码似乎可以工作,但问题是每当我将文本块的文本设置为 ascii 艺术时,它似乎有奇怪的换行符与我的代码无关

文本块中的 ASCII

输出日志中的 ASCII

在这里你可以看到输出日志正确显示了艺术,而文本块有换行符,我也尝试了 TextBox 和 RichTextBox,他们给了我相同的结果

图像到 Ascii 艺术代码

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();

    }

我该如何解决这个问题?

编辑: 重映射函数

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;
    }

}

字符字符串:

string chars = "    .:-^$@";

这是声明 chars 数组的地方,它只在我在上面问题中发布的 TurnImageToAscii 函数中引用过一次

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>

使用的图片

问题 1:ASCII 艺术无法在 WPF 中正确显示 TextBlock

  • ASCII-Art 需要使用 fixed-width(又名等宽)字体呈现。
  • 您发布的 XAML 表明您没有在 TextBlock 上设置 FontFamily。使用 <TextBlock> 的默认值 FontFamily,这将是 Segoe UI。
    • Segoe UI 不是等宽字体。
  • 问题不在于添加了额外的 line-breaks,而是渲染的线条被视觉压缩
  • 将您的 XAML 更改为此,它将起作用 as-expected:
    • 我还将 font-size 从 3 增加到 10
<TextBlock
    x:Name= "asciiTextBlock "
    HorizontalAlignment= "Left "
    Margin= "410,58,0,0 "
    VerticalAlignment= "Top "
    Height= "366 "
    Width= "380 "
    FontSize= "10 "
    FontFamily= "Courier New "
/>

截图证明:

使用默认字体:

Courier New:

我还制作了一个使用 Unicode 块字符的版本,只是为了确保我正确解码图像:

static readonly Char[] _chars = new[] { ' ', '░', '▒', '▓', '█' };


问题 2:性能

使用Bitmap.Lockbits:

像这样:

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( ' ' );
}
  • 还可以使用 StringBuilder 修改您的代码以提高效率:
    • 直接附加 char 值,而不是使用 .ToString()Char 转换为 String这很愚蠢
      • 使用 StringBuilder.Append(Char) 非常快,因为它只是将标量 char 值直接复制到 StringBuilder 的内部字符缓冲区中。
      • String 对象存在于堆上,需要分配和复制,因此与使用 char 值相比,它们相对昂贵,后者根本不需要使用堆 until/unless盒装。
    • 此外,通过设置 capacity: width * height 预分配 StringBuilder 意味着 StringBuilder 不需要在每次溢出时重新分配和复制其内部缓冲区。
      • StringBuilder 的默认容量仅为 16 个字符,因此如果您可以为其构造函数的 capacity: 预先计算 StringBuilder 的最终长度的上限,您应该这样做。

这是我得到的运行时间性能数据:

  • .NET 6 来自 运行ning 的 Linqpad 7 (.NET 6) x64 PC 上的 i7-10700K CPU 运行ning Windows 10 20H2 x64.
  • .NET 4.8 数字来自 运行ning 在 Linqpad 5 的 AnyCPU build.
  • 出于某种原因,我对 DEBUGRELEASE 构建进行了基准测试。
  • 我使用 Stopwatch 来测量将 already-loaded Bitmap 转换为 String 所花费的时间 - 因此它不包括加载图像的时间从磁盘,也不是 WPF 呈现文本的时间,因为这与我建议的改进无关)。
  • 显示的数字是初始预热后 3 运行 秒中的最佳数字 运行。
.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
  • 0.04ms 数字不是错字。真的是这么快。
  • 改进的 StringBuilder 使用确实有帮助,但我同意 sub-millisecond 时间的减少并不重要。
  • 我很欣赏 这个项目 在现代硬件上,即使是 worst-case 2.90 毫秒 也不错 GetPixel 方法,但我真的很惊讶“慢”方法现在有多快......
    • ...与大约 17 年前我第一次学习 .NET 并想使用 System.Drawing 创建动态 image-macro1[ 相比=177=] 我网站的生成器并尝试在我当时的 single-core Pentium 4 1.9Ghz(甚至 Hyper-Threading)上读取 500x500px Bitmap 至少需要几秒钟,这导致我 seek-out 一个更快的方法,毕竟,即使是我的旧 Pentium 166 也可以处理来自 real-time 中古老视频文件格式的 640x480 大小的位图图像,所以我假设我做错了什么。

这是我的代码,您应该可以将其复制并粘贴到 Linqpad 或一个新的空白 C# 项目中:

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\Downloads22-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 = 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 = 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: w * h );
    
    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 现在他们只是被称为 memes。这是在 Facebook 之前,在互联网成为主流之前,现在只是 lame 你知道吗?

截图证明: