【问题标题】:Performant method of drawing text onto a png file?将文本绘制到 png 文件上的高效方法?
【发布时间】:2021-05-20 07:25:17
【问题描述】:

我需要在一个(透明的)PNG 文件上绘制一个带有居中 TextSquares 二维网格。 瓷砖需要有足够大的分辨率,以便文本不会被像素化太多。

出于测试目的,我创建了一个 2048x2048px 32 位(透明)PNG 图像,其中包含 128x128px 的图块,例如:

问题是我需要以合理的性能做到这一点。到目前为止,我尝试过的所有方法都需要超过 100 毫秒才能完成,而我需要将其设置为最大 WebAssembly (但即使你知道如何使用 posix 线程等来做到这一点。我很乐意接受这也是一个起点)。

Net5 实现

using System.Diagnostics;
using System;
using System.Drawing;

namespace ImageGeneratorBenchmark
{
    class Program
    {
        static int rowColCount = 16;
        static int tileSize = 128;
        static void Main(string[] args)
        {
            var watch = Stopwatch.StartNew();

            Bitmap bitmap = new Bitmap(rowColCount * tileSize, rowColCount * tileSize);
            Graphics graphics = Graphics.FromImage(bitmap);

            Brush[] usedBrushes = { Brushes.Blue, Brushes.Red, Brushes.Green, Brushes.Orange, Brushes.Yellow };

            int totalCount = rowColCount * rowColCount;
            Random random = new Random();

            StringFormat format = new StringFormat();
            format.LineAlignment = StringAlignment.Center;
            format.Alignment = StringAlignment.Center;

            for (int i = 0; i < totalCount; i++)
            {
                int x = i % rowColCount * tileSize;
                int y = i / rowColCount * tileSize;

                graphics.FillRectangle(usedBrushes[random.Next(0, usedBrushes.Length)], x, y, tileSize, tileSize);
                graphics.DrawString(i.ToString(), SystemFonts.DefaultFont, Brushes.Black, x + tileSize / 2, y + tileSize / 2, format);
            }

            bitmap.Save("Test.png");

            watch.Stop();
            Console.WriteLine($"Output took {watch.ElapsedMilliseconds} ms.");
        }
    }
}

这在我的机器上大约需要 115 毫秒。我在这里使用System.Drawing.Common nuget。

保存位图大约需要 55ms,在循环中绘制到图形对象也大约需要 60ms,而 40ms 可以归因于绘制文本。

Rust 实现

use std::path::Path;
use std::time::Instant;
use image::{Rgba, RgbaImage};
use imageproc::{drawing::{draw_text_mut, draw_filled_rect_mut, text_size}, rect::Rect};
use rusttype::{Font, Scale};
use rand::Rng;

#[derive(Default)]
struct TextureAtlas {
    segment_size: u16, // The side length of the tile
    row_col_count: u8, // The amount of tiles in horizontal and vertical direction
    current_segment: u32 // Points to the next segment, that will be used 
}

fn main() {
    let before = Instant::now();

    let mut atlas = TextureAtlas {
        segment_size: 128,
        row_col_count: 16,
        ..Default::default()
    };

    let path = Path::new("test.png");
    let colors = vec![Rgba([132u8, 132u8, 132u8, 255u8]), Rgba([132u8, 255u8, 32u8, 120u8]), Rgba([200u8, 255u8, 132u8, 255u8]), Rgba([255u8, 0u8, 0u8, 255u8])];

    let mut image = RgbaImage::new(2048, 2048);

    let font = Vec::from(include_bytes!("../assets/DejaVuSans.ttf") as &[u8]);
    let font = Font::try_from_vec(font).unwrap();

    let font_size = 40.0;
    let scale = Scale {
        x: font_size,
        y: font_size,
    };

    // Draw random color rects for benchmarking
    for i in 0..256 {
        let rand_num = rand::thread_rng().gen_range(0..colors.len());

        draw_filled_rect_mut(
            &mut image, 
            Rect::at((atlas.current_segment as i32 % atlas.row_col_count as i32) * atlas.segment_size as i32, (atlas.current_segment as i32 / atlas.row_col_count as i32) * atlas.segment_size as i32)
                .of_size(atlas.segment_size.into(), atlas.segment_size.into()), 
            colors[rand_num]);

        let number = i.to_string();
        //let text = &number[..];
        let text = number.as_str(); // Somehow this conversion takes ~15ms here for 255 iterations, whereas it should normally only be less than 1us
        let (w, h) = text_size(scale, &font, text);
        draw_text_mut(
            &mut image, 
            Rgba([0u8, 0u8, 0u8, 255u8]), 
            (atlas.current_segment % atlas.row_col_count as u32) * atlas.segment_size as u32 + atlas.segment_size as u32 / 2 - w as u32 / 2, 
            (atlas.current_segment / atlas.row_col_count as u32) * atlas.segment_size as u32 + atlas.segment_size as u32 / 2 - h as u32 / 2, 
            scale, 
            &font, 
            text);

        atlas.current_segment += 1;
    }

    image.save(path).unwrap();

    println!("Output took {:?}", before.elapsed());
}

对于 Rust,我使用的是 imageproc crate。以前我使用piet-common crate,但输出耗时超过 300 毫秒。使用imageproc crate,我在发布模式下得到了大约 110ms,这与 C# 版本相当,但我认为它在使用 webassembly 时会表现得更好。

当我使用静态字符串而不是从循环中转换数字时(见评论),我得到了低于 100 毫秒的执行时间。 Rust 绘制到图像只需要大约 30 毫秒,但保存需要 80 毫秒。

C++ 实现

#include <iostream>
#include <cstdlib>
#define cimg_display 0
#define cimg_use_png
#include "CImg.h"
#include <chrono>
#include <string>

using namespace cimg_library;
using namespace std;

/* Generate random numbers in an inclusive range. */
int random(int min, int max)
{
    static bool first = true;
    if (first)
    {
        srand(time(NULL));
        first = false;
    }
    return min + rand() % ((max + 1) - min);
}

int main() {
    auto t1 = std::chrono::high_resolution_clock::now();

    static int tile_size = 128;
    static int row_col_count = 16;

    // Create 2048x2048px image.
    CImg<unsigned char> image(tile_size*row_col_count, tile_size*row_col_count, 1, 3);

    // Make some colours.
    unsigned char cyan[] = { 0, 255, 255 };
    unsigned char black[] = { 0, 0, 0 };
    unsigned char yellow[] = { 255, 255, 0 };
    unsigned char red[] = { 255, 0, 0 };
    unsigned char green[] = { 0, 255, 0 };
    unsigned char orange[] = { 255, 165, 0 };

    unsigned char colors [] = { // This is terrible, but I don't now C++ very well.
        cyan[0], cyan[1], cyan[2],
        yellow[0], yellow[1], yellow[2],
        red[0], red[1], red[2],
        green[0], green[1], green[2],
        orange[0], orange[1], orange[2],
    };

    int total_count = row_col_count * row_col_count;

    for (size_t i = 0; i < total_count; i++)
    {
        int x = i % row_col_count * tile_size;
        int y = i / row_col_count * tile_size;

        int random_color_index = random(0, 4);
        unsigned char current_color [] = { colors[random_color_index * 3], colors[random_color_index * 3 + 1], colors[random_color_index * 3 + 2] };

        image.draw_rectangle(x, y, x + tile_size, y + tile_size, current_color, 1.0); // Force use of transparency. -> Does not work. Always outputs 24bit PNGs.

        auto s = std::to_string(i);

        CImg<unsigned char> imgtext;
        unsigned char color = 1;
        imgtext.draw_text(0, 0, s.c_str(), &color, 0, 1, 40); // Measure the text by drawing to an empty instance, so that the bounding box will be set automatically.

        image.draw_text(x + tile_size / 2 - imgtext.width() / 2, y + tile_size / 2 - imgtext.height() / 2, s.c_str(), black, 0, 1, 40);
    }

    // Save result image as PNG (libpng and GraphicsMagick are required).
    image.save_png("Test.png");

    auto t2 = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();

    std::cout << "Output took " << duration << "ms.";
    getchar();
}

我还使用CImg 在 C++ 中重新实现了相同的程序。对于 .png 输出,libpngGraphicsMagick 也是必需的。我对 C++ 不是很流利,我什至没有费心去优化,因为在发布模式下保存操作大约需要 200 毫秒,而当前非常未优化的整个图像生成只需要 30 毫秒。所以这个解决方案也远未达到我的目标。

我现在在哪里

我现在在哪里的图表。当我取得一些进展时,我会更新这个。

我为什么要这样做以及为什么它让我如此困扰

cmets 要求我提供更多背景信息。我知道这个问题有点臃肿,但如果你有兴趣继续阅读......

所以基本上我需要为 .gltf 文件构建一个 Texture Atlas。我需要从数据生成一个 .gltf 文件,并且 .gltf 文件中的图元也将根据输入数据分配一个纹理。为了优化少量的绘制调用,我将尽可能多的几何体放入一个图元中,然后使用纹理坐标将纹理映射到模型。现在 GPU 具有纹理可以具有的最大尺寸。我将使用 2048x2048 像素,因为大多数设备至少支持该像素。这意味着,如果我有超过 256 个对象,我需要向 .gltf 添加一个新基元并生成另一个纹理图集。在某些情况下,一个纹理图集可能就足够了,而在其他情况下,我需要多达 15-20 个。

纹理将具有(半)透明背景,可能是文本,也可能是一些线条/影线或简单符号,可以用路径绘制。

我已经在 Rust 中设置了整个系统,并且 .gltf 生成非常有效:我可以在大约 10 毫秒内生成 54000 个顶点(例如=1500 个框),这是一种常见的情况。现在为此我需要生成 6 个纹理图集,这在多核系统上并不是一个真正的问题(7 个线程,一个用于 .gltf,六个用于纹理)。问题是生成一个需要大约 100 毫秒(或现在 55 毫秒),这使得整个过程慢了 5 倍以上。

不幸的是,它变得更糟,因为另一个常见的情况是 15000 个对象。生成顶点(实际上还有很多自定义属性)和组装 .gltf 仍然只需要 96 毫秒(540000 顶点/20MB .gltf),但那时我需要生成 59 个纹理图集。我正在开发一个 8 核系统,所以那时我不可能并行运行它们,我必须为每个线程生成约 9 个图集(这意味着 55ms*9 = 495ms)所以这又是 5倍,实际上造成了相当明显的滞后。实际上,目前它需要超过 2.5 秒,因为我已经更新为使用更快的代码,而且似乎还有额外的减速。

我需要做什么

我知道写出 4194304 个 32 位像素需要一些时间。但据我所见,因为我只写入图像的不同部分(例如只写入上部瓷砖等),所以应该可以构建一个使用多个线程执行此操作的程序。这就是我想尝试的,并且我会就如何让我的 Rust 程序运行得更快提出任何提示。

如果有帮助,我也愿意用 C 或任何其他语言重写它,它可以编译为 wasm,并且可以通过 Rust 的 FFI 调用。因此,如果您对性能更高的库有任何建议,我也会非常感谢。

编辑

更新 1: 我从 cmets 对 C# 版本进行了所有建议的改进。感谢他们所有人。它现在是 115 毫秒,几乎和 Rust 版本一样快,这让我相信我在那里遇到了死胡同,我真的需要找到一种方法来并行化它,以便做出重大的进一步改进。 .

更新 2:感谢@pinkfloydx33,在使用dotnet publish -p:PublishReadyToRun=true --runtime win10-x64 --configuration Release 发布二进制文件后,我能够在大约 60 毫秒(包括第一次运行)内运行二进制文件。

与此同时,我自己也尝试了其他方法,即 Python 使用 Pillow (~400ms)、C# 和 Rust 都使用 Skia (~314ms 和 ~260ms),我还使用 @987654328 在 C++ 中重新实现了程序@(和 libpng 以及 GraphicsMagick)。

【问题讨论】:

  • 并不是说它会有多大帮助,但您可以在循环外缓存Math.Pow() 的计算,而不是每次都重新计算。 i.ToString 可能缓存了?标准做法:避免创建new Random,尤其是在每次迭代中。在循环之外或作为静态字段创建它。我怀疑它会比 GDI/native 更快,但您也许可以 LockBits 并手动在矩形中着色并仅将 native 用于文本
  • 您可以尝试使用居中的字符串格式和矩形替换测量部分
  • 你也可以试试这个:stackoverflow.com/a/26498/491907 将文本垂直和水平居中而不是每次都重新计算
  • 您的原始解决方案在我的机器上运行时间约为 380 毫秒;只需使用上面的建议,我就可以将它降低到 ~70ms(在我的机器上)。您可能想尝试一下,看看效果如何(看起来您的机器可能比我的好)。我尝试使用 LockBits+unsafe 绘制矩形,然后使用 GDI 进行测试,但效果更糟
  • 是的,您必须多次运行它才能让 JIT 启动。首先在循环中运行每个方法大约 15-20 次,然后丢弃并再次运行您的方法(穷人的基准测试)。还要确保您正在运行 RELEASE 构建,而不是在 Visual Studio 中执行它(从命令行运行)

标签: c# multithreading performance rust concurrency


【解决方案1】:

我能够通过以下方式将所有绘图(创建网格和文本)缩短到 4-5 毫秒:

  • 尽可能缓存值(RandomStringFormatMath.Pow
  • 使用ArrayPool 作为暂存缓冲区
  • 使用DrawString 重载接受StringFormat 和以下选项:
    • AlignmentLineAlignment 用于居中(代替手动计算)
    • FormatFlagsTrimming 选项会禁用诸如溢出/换行之类的功能,因为我们只是在写小数字(这会产生影响,尽管可以忽略不计)
  • 使用来自GenericMonospace 字体系列的自定义Font 而不是SystemFonts.DefaultFont
    • 这缩短了大约 15 毫秒
  • 摆弄各种Graphics 选项,例如TextRenderingHintSmoothingMode
    • 我得到了不同的结果,所以你可能想再弄一些
  • ColorToArgb 函数的数组,用于创建代表像素颜色的 4 倍 bytes 的 int
  • 使用LockBits、(半)unsafe代码和Span
    • 用代表随机颜色的 ARGB 值的 int 填充代表 1px 高和 size * countpx 宽(整个图像宽度)的缓冲区
    • 将该缓冲区复制size 次(现在代表整个正方形的高度)
    • 冲洗/重复
    • 需要unsafe 从锁定位的Scan0 指针创建Span&lt;&gt;
  • 最后,使用 GDI/native 在图形上绘制文本

然后通过使用Image.Save(Stream) 重载,我能够从实际的保存 过程中节省一点时间。我使用了FileStream,自定义缓冲区大小为 16kb(超过默认的 4kb),这似乎是最佳选择。这使总的端到端时间减少到大约 40 毫秒(在我的机器上)。

private static readonly Random Random = new();
private static readonly Color[] UsedColors = { Color.Blue, Color.Red, Color.Green, Color.Orange, Color.Yellow };
private static readonly StringFormat Format = new()
{
    Alignment = StringAlignment.Center, 
    LineAlignment = StringAlignment.Center,
    FormatFlags = StringFormatFlags.NoWrap | StringFormatFlags.FitBlackBox | StringFormatFlags.NoClip,
    Trimming = StringTrimming.None, HotkeyPrefix = HotkeyPrefix.None
};

private static unsafe void DrawGrid(int count, int size, bool save)
{

    var intsPerRow = size * count;
    var sizePerFullRow = intsPerRow * size;
    var colorsLen = UsedColors.Length;

    using var bitmap = new Bitmap(intsPerRow, intsPerRow, PixelFormat.Format32bppArgb);

    var bmpData = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);

    var byteSpan = new Span<byte>(bmpData.Scan0.ToPointer(), Math.Abs(bmpData.Stride) * bmpData.Height);
    var intSpan = MemoryMarshal.Cast<byte, int>(byteSpan);

    var arr = ArrayPool<int>.Shared.Rent(intsPerRow);
    var buff = arr.AsSpan(0, intsPerRow);

    for (int y = 0, offset = 0; y < count; ++y)
    {
        // fill buffer with an entire 1px row of colors
        for (var bOffset = 0; bOffset < intsPerRow; bOffset += size)
            buff.Slice(bOffset, size).Fill(UsedColors[Random.Next(0, colorsLen)].ToArgb());

        // duplicate the pixel high row until we've created a row of squares in full
        var len = offset + sizePerFullRow;
        for ( ; offset < len; offset += intsPerRow)
            buff.CopyTo(intSpan.Slice(offset, intsPerRow));
    }

    ArrayPool<int>.Shared.Return(arr);

    bitmap.UnlockBits(bmpData);

    using var graphics = Graphics.FromImage(bitmap);

    graphics.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;

    // some or all of these may not even matter?
    // you may try removing/modifying the rest
    graphics.CompositingQuality = CompositingQuality.HighSpeed;
    graphics.InterpolationMode = InterpolationMode.Default;
    graphics.SmoothingMode = SmoothingMode.HighSpeed;
    graphics.PixelOffsetMode = PixelOffsetMode.HighSpeed;
    
    var font = new Font(FontFamily.GenericMonospace, 14, FontStyle.Regular);

    var lenSquares = count * count;
    for (var i = 0; i < lenSquares; ++i)
    {
        var x = i % count * size;
        var y = i / count * size;

        var rect = new Rectangle(x, y, size, size);
        graphics.DrawString(i.ToString(), font, Brushes.Black, rect, Format);   
    }

    if (save)
    {
        using var fs = new FileStream("Test.png", FileMode.Create, FileAccess.Write, FileShare.Write, 16 * 1024);
        bitmap.Save(fs, ImageFormat.Png);
    }
}

以下是在 Release 模式下使用 StopWatch 在 Visual Studio 之外运行的计时(以毫秒为单位)。至少前 1 或 2 个时间应该被忽略,因为这些方法还没有完全 jitted。您的里程将根据您的 PC 等而有所不同。

仅图像生成:

Elapsed: 38
Elapsed: 6
Elapsed: 4
Elapsed: 4
Elapsed: 4
Elapsed: 4
Elapsed: 5
Elapsed: 4
Elapsed: 5
Elapsed: 4
Elapsed: 4

图像生成和保存:

Elapsed: 95
Elapsed: 48
Elapsed: 41
Elapsed: 40
Elapsed: 37
Elapsed: 42
Elapsed: 42
Elapsed: 39
Elapsed: 38
Elapsed: 40
Elapsed: 41

我认为对于慢速保存没有什么可以做的。我查看了Image.Save 的源代码。它调用 Native/GDI,将句柄传递给 Stream、本地图像指针和代表 PNG 的 ImageCodecInfo(编码器)的 Guid。任何缓慢都会出现在这一点上。 更新:我已经验证您在保存到 MemoryStream 时会遇到同样缓慢的速度,因此这与您保存到文件和 所有内容这一事实无关与 GDI/native 幕后发生的事情有关。

我还尝试使用直接unsafe(指针)和/或使用UnsafeMemoryMarshal(例如CopyBlock)的技巧以及展开循环来进一步绘制图像。这些方法要么产生相同的结果,要么更糟,让事情变得更难遵循。

注意:使用PublishReadyToRun=true 发布为控制台应用程序似乎也有点帮助。

更新

我知道以上只是一个示例,因此这可能不适用于您的最终目标。经过进一步的广泛审查,我发现所花费的时间大部分实际上是Image::Save 的一部分。我们保存到什么类型的Stream 并不重要,即使MemoryStream 表现出同样的缓慢(显然忽略文件I/O)。我相信这与图像/图形中的 GDI 对象有关——在我们的例子中是来自DrawString 的文本。

作为“简单”测试,我更新了上述内容,以便在全白的辅助图像上绘制文本。在不保存该图像的情况下,然后我循环遍历其各个像素并基于 rough 颜色(因为我们要处理锯齿),我在主位图上手动设置了相应的像素。整个端到端过程在我的机器上花费了不到 20 毫秒。渲染的图像并不完美,因为它是一个快速测试,但它证明您可以手动完成部分操作,并且仍然可以实现非常短的时间。问题在于文本绘制,但我们可以利用 GDI 而无需在最终图像中实际使用它。你只需要找到甜蜜点。我还尝试使用索引格式并预先用颜色填充调色板似乎也有所帮助。无论如何,只是值得深思。

【讨论】:

  • 请问您使用的是什么 .Net 版本?我使用 .Net5 并使用dotnet run --configuration Release 从命令行运行程序,我得到了一个整体最佳时间,节省了 103 毫秒,并且没有至少 40 毫秒(因此节省了 60 毫秒)。我怀疑它只是我的机器,这要糟糕得多。顺便说一句,我在 Windows 10 上。
  • 啊,我明白了。我认为多次调用该程序就足够了,但事实并非如此。当我在测量之前调用该方法 20 次时,需要 60 毫秒。
  • 如果您正在构建 net5 应用程序(相对于库),请考虑在启用 ReadyToRun 的情况下对其进行编译。 docs.microsoft.com/en-us/dotnet/core/deploying/ready-to-run
  • 我修复了一个编译错误。它现在运行得稍微快了一点!
  • 是的,快了两毫秒。
猜你喜欢
  • 2018-01-21
  • 1970-01-01
  • 2019-06-22
  • 2017-12-24
  • 2013-04-21
  • 1970-01-01
  • 2012-03-12
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多