maemaewaterの日記

エンジニア兼ゲーマーの人の日記です。PHP/Python/JavaScript/C#/C++などによるプログラムに関することを主に書いています。

C#でPNG形式の画像を読み込む (.NET)

はじめに

サーバーレス環境などで.NETが利用できたりしますが、実行環境のCPUアーキテクチャx86とは限らないケースも多くなってくると思われます。実行される環境もWindowsであったりLinuxであったり様々なケースがあり得そうです。そこで、マネージドコードのみで画像の読み込みが手軽にできればということで、PNG形式の画像の読み込みを行ってみようという内容になります。

開発環境など

今回はサーバーレスでも実行可能な.Net Core (3.1)で進めていきます。開発の方針としては、標準で用意されているクラスライブラリ以外は使用しないということになります。また、すべてのPNG形式のパターンを網羅するのはこれからの課題として、最小限の読み込みが可能なものを作成していきます。

PNGのファイル形式について

PNGのファイル形式については仕様が公開されているので、こちらのドキュメントを読みながら進めていきます。

PNG形式の基本的な構造

バイナリ形式のファイルになり全体的には次のような形になります。

ヘッダーの4バイトを除いて、そのほかはチャンクと呼ばれるデータの塊にまとめられています。チャンクの種類はいくつかありますが、基本構造はどれも一緒です。

  • ヘッダー (8バイト)
  • 画像の基本情報: IHDRチャンク
    • 画像の縦と横のピクセル数、色の深度などが記述されています。
  • 画像のデータ本体: IDATチャンク
    • 画像の各ピクセルの値(R, G, B, Aなど)が格納されています。
    • GZip形式で圧縮されています。
    • 格納のされ方は様々なので後で説明していきます。
  • 終わりのお知らせ: IENDチャンク

C# (.NET)でバイナリファイルを読み込む

バイナリファイルを読み込む場合にはBinaryReaderクラスを使うと楽になります。

using System.IO;

...

var reader = new BinaryReader(File.Open("filename.png", FileMode.Open));
byte[] bytes = reader.ReadBytes(8);

数値も以下のように簡単に読めます。

uint x = reader.ReadUInt32();

しかし、PNGのバイトオーダーは逆になりますので、一度byteの配列に格納して順番を逆にしてあげる必要があります(参照)。

byte[] bytes = reader.ReadBytes(4);
uint x = BitConverter.ToUInt32(bytes.Reverse().ToArray());

(using System.Linqを追加しておきます)

数値情報を読む場合には上のように逆に並び替えてからBitConverterを使用していきます。(そうしないと値が大きかったりおかしなことになってしまいます)

ヘッダーを読む

ヘッダーについては、仕様書のこちらに書かれています。 先頭の8倍とを読んでいけばよいので、先頭が次のようになっているかチェックすれば大丈夫です。もし、ここが違えばPNG形式ではないので処理を 中止するようにします。

1 2 3 4 5 6 7 8
137 80 78 71 13 10 26 10

(数値は10進数)

private bool ReadPngSignature(BinaryReader reader)
{
    byte[] signature = reader.ReadBytes(8);

    if (signature[0] == 137 && signature[1] == 80 && signature[2] == 78 && signature[3] == 71 &&
        signature[4] == 13 && signature[5] == 10 && signature[6] == 26 && signature[7] == 10)
    {
        return true;
    }

    return false;
}

チャンクを読む

チャンクの情報を読んでいきます(こちら)。 チャンクは次のような形式になっています。

バイト数 名称 内容
4 Length unsigned int チャンクに格納されているChunk Dataの長さ
4 Chunk Type unsigned int チャンクの種類。主にIHDR, IDAT, IENDがあります
Lengthのバイト数 Chunk Data byteの配列 チャンクに格納されている実際のデータ
4 CRC unsigned int データの内容が正しいかどうかのチェック用(CRC)

チャンクの情報を格納しておく構造体(Chunk)は次の通りです。

struct Chunk
{
    public uint Length;
    public byte[] ChunkType;
    public byte[] ChunkData;
    public uint Crc;

    public Chunk(uint length, byte[] chunkType, byte[] chunkData, uint crc)
    {
        Length = length;
        ChunkType = chunkType;
        ChunkData = chunkData;
        Crc = crc;
    }
}

チャンクのデータの読み込みは次のようにします。 長さなどそれぞれのデータを読んでChunk構造体に格納しています。

private Chunk ReadChunk(BinaryReader reader)
{
    byte[] bytesLength = reader.ReadBytes(4);
    byte[] bytesChunkType = reader.ReadBytes(4);

    uint length = BitConverter.ToUInt32(bytesLength.Reverse().ToArray());

    byte[] chunkData = reader.ReadBytes((int)length);

    byte[] bytesCrc = reader.ReadBytes(4);
    uint crc = BitConverter.ToUInt32(bytesCrc.Reverse().ToArray());

    return new Chunk(length, bytesChunkType, chunkData, crc);
}

これで、PNG画像の幅や高さをの取得やピクセル値を読み取る準備が整いました!

IHDRチャンクの場合の処理

画像の幅、高さ、色の深度が格納されているのがIHDRチャンクになります。説明はこちら。 次のような構造になっています。

バイト数 名称 内容
4 Width unsigned int 画像の横のピクセル
4 Height unsigned int 画像の縦のピクセル
1 Bit Depth byte 色の深度。8の場合は(R, G, B)のRなどに対して8 bit
1 Color Type byte 色の種類。0がグレースケール, 2がRGB, 6がRGBAなど
1 Compression method byte IDATチャンクの圧縮方法。0の場合はdeflateが使用される
1 Filter Method byte ピクセルの値の格納方法。直接の値の場合は0、左隣のピクセルとの差分の場合は1など
1 Interlace Method byte インターレースの方法。インターレースなしの場合は0

Chunk構造体のChunkDataから読み取っていきます。

void ReadIHDR(Chunk chunk)
{
    using MemoryStream stream = new MemoryStream(chunk.ChunkData);
    using BinaryReader reader = new BinaryReader(stream);

    byte[] bytesWidth = reader.ReadBytes(4);
    int Width = (int)BitConverter.ToUInt32(bytesWidth.Reverse().ToArray());

    byte[] bytesHeight = reader.ReadBytes(4);
    int Height = (int)BitConverter.ToUInt32(bytesHeight.Reverse().ToArray());

    int BitDepth = (int)reader.ReadByte();
    int ColorType = (int)reader.ReadByte();
    int CompressionType = (int)reader.ReadByte();
    int FilterMethod = (int)reader.ReadByte();
    int InterlaceMethod = (int)reader.ReadByte();
}

byteをintにしている箇所がありますが、byteのままで問題ないと思います。BinaryRaderを通して処理すると シンプルになりそうでしたので、BinaryReaderを使用しています。

IDATチャンクの場合の処理

ついにピクセル値が格納されているIDATチャンクです!

IHDRチャンクで様々な形式で格納されていることが分かりましたが、ここでは絞って進めていきます。 Compression Methodは0のdeflate、Color Typeは2のRGB、Filter Methodは差分の1、Interlace Methodは0のなしになります。

ドキュメントの場所はこちらになります。 全体的な流れとしては、IDATチャンクに格納されているデータは次の流れで処理されたものになります。

  1. Image Layoutに沿ってピクセル値を格納。画像の1行(scanline)の先頭にはフィルタの方式(1バイト)を記述する。
  2. 圧縮

このため読み込む場合には、圧縮されたデータを解凍してから、各ピクセルの値を取得するようにします。 DeflateStreamを使用すると解凍することができますが、 IDATのデータの先頭の2バイト圧縮方法などが記述されたヘッダーになりますので削除してから解凍するように しています。

ピクセル値を格納する構造体(ColorRGBA)は次のようになります。

public struct ColorRGBA
{
    public byte R;
    public byte G;
    public byte B;
    public byte A;

    public ColorRGBA(byte r, byte g, byte b, byte a)
    {
        this.R = r;
        this.G = g;
        this.B = b;
        this.A = a;
    }
}
private void ReadIDAT(Chunk chunk)
{
    // 2バイトのヘッダーは取り除く
    byte[] data = chunk.ChunkData.Skip(2).ToArray();

    using MemoryStream stream = new MemoryStream(data);
    stream.Position = 0;

    using DeflateStream decompressStream = new DeflateStream(stream, CompressionMode.Decompress);
    using MemoryStream dataStream = new MemoryStream();
    decompressStream.CopyTo(dataStream);
    dataStream.Position = 0;
    using BinaryReader pixels = new BinaryReader(dataStream);

    byte R = 0;
    byte G = 0;
    byte B = 0;
    byte A = 0;

  
    for (int y = 0; y < Height; y++)
    {
        byte filterType = pixels.ReadByte();

        if (filterType == 1)
        {
            ColorRGBA previousColor = new ColorRGBA(0, 0, 0, 0);

            for (int x = 0; x < Width; x++)
            {
                R = pixels.ReadByte();
                G = pixels.ReadByte();
                B = pixels.ReadByte();

                ColorRGBA color = new ColorRGBA((byte)(previousColor.R + R), (byte)(previousColor.G + G), (byte)(previousColor.B + B), 255);
                pixelsRGBA.Add(color);

                previousColor = color;
            }
        }
    }
}

IENDチャンクの場合の処理

こちらはPNGの終わりを示しているので、このチャンクがでてきたら読み込みを終了するようにします。

おわりに

ピクセルの値の取得までざっくりとまとめましたがいかがでしたでしょうか? 何かのお役に立てばと思っております。