てくメモ

trivial な notes

【C#】ImageSharp 3.0 : ピクセルを扱ってアートをつくってみる

C# (.NET) で画像を扱うためにはいくつか選択肢があるが、ImageSharp はそのひとつ。
GitHub - SixLabors/ImageSharp: A modern, cross-platform, 2D Graphics library for .NET

他の選択肢に対して full managed だがパフォーマンスに劣るというような感じだったが、現メジャーバージョンの 3.0 では Span を通してピクセル操作を行うAPIが用意されるなど、パフォーマンス的な見直しが図られているらしい。

試してみる。


成果物のアニメーションを最初にペタリ。
(APNG なのでブラウザ次第で動いて見えないかもしれない)
https://i.imgur.com/qJaG7XC.png

お試しチェッカーフラッグ

さて、ドキュメントによれば、ピクセル操作はまずインデクサで行える。(例:image[x, y]

しかし、効果的な操作を行うには Span ベースなPixelAccessor<TPixel>を使うということ。
参考:Working with Pixel Buffers


まず試しに、チェッカーフラッグを生成してみる。

Rgba32 fg = Color.Parse("3f2f53");
Rgba32 bg = Color.Parse("fafafa");
const int width = 360, height = 240, interval = width / 6;
using Image<Rgba32> image = new(width, height, bg);

Stopwatch watch = new();
watch.Start();
// -----
image.ProcessPixelRows(accessor =>
{
    var fillsColor = false;
    for (int i = 0; i < accessor.Height; i++)
    {
        var row = accessor.GetRowSpan(i);

        if ((i % interval) == 0)
            fillsColor = !fillsColor;

        for (int j = 0; j < accessor.Width; j += interval)
        {
            if (fillsColor)
                row[j..(j + interval)].Fill(fg);

            fillsColor = !fillsColor;
        }
    }
});
// -----
Console.WriteLine($"it took {watch.ElapsedMilliseconds} ms.");
// it took 2 ms.

image.SaveAsPng("checkerFlag.png");

ProcessPixelRowsメソッドでアクセス用の ref 構造体を受け取り、GetRowSpanで行 Span を受け取って操作する。

Stopwatch計測では数ミリ秒で、ライブラリの中身をきちんと見ていないけれど、余計なことはやっていないというのを感じる。

最後に画像を保存しているが、このファイルI/Oと較べれば極めて安い。

画像の最も複雑な領域を四分し続けるアート

もう少し処理の多い例をやってみたい。

以下の記事を参考とさせてもらい、画像の最も複雑な領域を四分し続けるアートを作成してみる。
四分木の中で最も複雑な領域を分割し続けるアートの作り方


コードを以下に折りたたむ。

コード全文(折りたたみ)

internal class Program
{
    static void Main(string[] args)
    {
        bool isSeq = false;
        Stopwatch watch = new();
        if (isSeq)
        {
            if (Directory.Exists("output") is false) Directory.CreateDirectory("output");
        }
        else
        {
            watch.Start();
        }

        int iterLimit = 512;

        var path = "input.jpg";
        using var original = Image.Load<Rgba32>(path);
        if (original is null) return;
        using Image<Rgba32> canvas = new(original.Width, original.Height);

        PriorityQueue<Rectangle, double> q = new(Comparer<double>.Create((a, b) => -a.CompareTo(b)));
        q.Enqueue(new(0, 0, original.Width, original.Height), 1);

        int counter = 0;
        while(counter++ < iterLimit && q.TryDequeue(out var curRect, out var priority))
        {
            Console.WriteLine($"{counter:000000}: score={priority}");
            if (priority < 0) break;

            var (x, y, w, h) = curRect;
            var halfW = w / 2;
            var halfH = h / 2;

            // 数字は象限
            Rectangle rect2 = new(x,         y,         halfW,     halfH);
            Rectangle rect1 = new(x + halfW, y,         w - halfW, halfH);
            Rectangle rect4 = new(x,         y + halfH, halfW,     h - halfH);
            Rectangle rect3 = new(x + halfW, y + halfH, w - halfW, h - halfH);

            Rgba32 ave1 = default, ave2 = default, ave3 = default, ave4 = default;
            double score1 = default, score2 = default, score3 = default, score4 = default;

            Rgba32 stroke = Color.MidnightBlue;
            // 矩形それぞれの対象ピクセルが被らないので戯れに並列してみる
            Parallel.Invoke(
                () =>
                {
                    GetAverageColorAndScore(original, rect1, out ave1, out score1);
                    DrawRect(canvas, rect1, stroke, ave1);
                },
                () =>
                {
                    GetAverageColorAndScore(original, rect2, out ave2, out score2);
                    DrawRect(canvas, rect2, stroke, ave2);
                },
                () =>
                {
                    GetAverageColorAndScore(original, rect3, out ave3, out score3);
                    DrawRect(canvas, rect3, stroke, ave3);
                },
                () =>
                {
                    GetAverageColorAndScore(original, rect4, out ave4, out score4);
                    DrawRect(canvas, rect4, stroke, ave4);
                });

            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            static double getAdjustment(Rectangle rect)
            {
                // 最低サイズ補正
                // いくつかの画像を試した限り、絵のサイズと種類で適正な値がかなり異なる印象
                const int limitSize = 24;
                if (rect.Width < limitSize || rect.Height < limitSize)
                    return double.MinValue;

                // 矩形サイズ補正
                // これも上と同様、真面目にやるとかなり難しそうだと感じた
                const double m = 0.2;
                var area = rect.Width * rect.Height;
                return Math.Pow(area, m);
            }

            score1 += getAdjustment(rect1);
            score2 += getAdjustment(rect2);
            score3 += getAdjustment(rect3);
            score4 += getAdjustment(rect4);

            q.Enqueue(rect1, score1);
            q.Enqueue(rect2, score2);
            q.Enqueue(rect3, score3);
            q.Enqueue(rect4, score4);

            if(isSeq)
                canvas.SaveAsPng($"output/{counter:000000}.png");
        }

        if(isSeq is false)
        {
            canvas.SaveAsPng($"result.png");
            Console.WriteLine($"it took {watch.ElapsedMilliseconds} ms");
            // 1024x1024ピクセル、iterLimit = 512 で500数十ミリ秒
        }
    }

    private static void GetAverageColorAndScore(Image<Rgba32> image, Rectangle rect, out Rgba32 averageColor, out double score)
    {
        var (x, y, w, h) = rect;
        var right = rect.Right;
        var bottom = rect.Bottom;

        Vector3 cSum = new(); // 計算の利便を図って Vector
        int count = 0;

        for (int i = y; i < bottom; i++)
        {
            image.ProcessPixelRows(acc =>
            {
                var row = acc.GetRowSpan(i)[x..right];
                foreach(var p in row)
                {
                    cSum += new Vector3(p.R, p.G, p.B);
                    count++;
                }
            });
        }

        Vector3 average = cSum / count;
        double sigma = 0;

        for (int i = y; i < bottom; i++)
        {
            image.ProcessPixelRows(acc =>
            {
                var row = acc.GetRowSpan(i)[x..right];
                foreach (var p in row)
                {
                    var current = new Vector3(p.R, p.G, p.B);
                    sigma += Vector3.DistanceSquared(current, average); // RGBのユークリッド距離(の二乗)
                }
            });
        }

        // out:
        averageColor = new((byte)average.X, (byte)average.Y, (byte)average.Z);
        score = Math.Sqrt(sigma / count);
    }

    private static void DrawRect(Image<Rgba32> image, Rectangle rect, Rgba32 stroke, Rgba32 fill)
    {
        var (x, y, w, h) = rect;
        var right = rect.Right;

        image.ProcessPixelRows(acc =>
        {
            // 上辺
            var upper = acc.GetRowSpan(y)[x..right];
            upper.Fill(stroke);

            // 間
            var guard = y + h - 1;
            for (int i = y + 1; i < guard; i++)
            {
                var row = acc.GetRowSpan(i)[x..right];
                row[0] = stroke;
                row[1..^1].Fill(fill);
                row[^1] = stroke;
            }

            // 下辺
            var lower = acc.GetRowSpan(guard)[x..right];
            lower.Fill(stroke);
        });
    }
}
  • Rgba32にはVector3を受けるコンストラクタや、ToVector4といったメソッドがあるが、これらは [0-1] の正規化された値を扱う(最初、「お、Vector3 を受けるコンストラクタがあるな」と思って [0-255] の範囲の値を渡したら真っ白で、確認したらクランプされていた。ArgumentOutOfRangeExceptionを出してほしいような……)


・APNG 作成に使ったコマンド(ffmpeg 利用)

ffmpeg -framerate 60 -i "output/%%06d.png" -vf scale=360:-1  -final_delay 1 -plays 0 -r 24 out.apng


・アニメーション(APNG)
https://i.imgur.com/qJaG7XC.png

・入力画像

Bing Image Creator


行うピクセル操作は、次のような内容を再帰的に繰り返している。

  • スコアの高い矩形について四分し、オリジナル画像のそれぞれの範囲を走査してピクセルの平均色を取る
  • キャンバスのそれぞれの範囲を平均色で範囲を塗りつぶす
  • オリジナル画像のそれぞれの範囲を走査して平均色とあわせスコアを求める

今回の入力の1024x1024ピクセル、繰り返し回数512で、Stopwatch計測では数百ミリ秒(500数十ミリ秒)ほどだった。
当然のことながら、アニメーションのために連番画像を保存する場合にはそちらの方がボトルネックだった。


扱いにまったく癖もなく、今回のような範囲では ImageSharp は悪くないように感じた。


以下、直接テーマと関係ない話題


・羽の暗い部分が過小評価され過ぎな気がする
調べていないけれど、認知的により適したスコア付けがありそう。


・人間のパターン認知って不思議
次の画像は、48分割め。

48分割め

これくらいでもなんとなく認識できる。
もう少しスコア補正を調整すればクイズ企画などで使える気がする。