e.blog

主にUnity/UE周りのことについてまとめていきます

PNGデータを自前で展開してテクスチャ化する

概要

前回の記事PNGデータの構造とテキストチャンクにデータを書き込むことを書きました。

今回はさらに話を進めて、自前でPNGデータを展開しテクスチャ化するまでを書いてみようと思います。またさらに、速度を上げるためにポインタを直に使っています。(それでもUnityのネイティブ実装に比べるとだいぶ遅いですが、すべてを非同期にできるので多少は有用性があるかも)

なお、PNGにはいくつかのカラータイプがありますが、今回はあくまで内容把握が目的なのでαチャンネルありのカラー限定で対応しています。

例によって今回の実装もGitHubに上がっているので、実際の動作・コードを確認したい人はそちらをご覧ください。

github.com



PNGのデータ構造

全体的な仕様は前回の記事を参照ください。ここでは IDAT チャンク、つまり画像データそのものについて書いていきます。

PNGデータは複数の IDAT チャンクから構成される

PNGデータは複数のチャンクデータから成り、実際の画像データとしての部分は IDAT チャンクと呼ばれるチャンクに格納されています。またさらに、このチャンクは複数個ある場合があり、その場合はすべての IDAT チャンクのデータ部を結合したひとつのバイト配列が画像データを表すデータとなります。

前回の記事の画像を引用すると以下のような構成になっています。

f:id:edo_m18:20220213230255p:plain

ここの IDAT チャンクが(場合によっては)複数個配置されているというわけですね。

PNGデータは圧縮されている

実は IDAT チャンクのデータ部分を結合しただけでは画像として利用できません。というのも、このデータ部分は Deflate 圧縮が施されているのでそれを先に解凍する必要があります。

データを復元する

それを踏まえて、データを取り出している部分のコードを抜粋します。

データを取り出すために以下ふたつの構造体を定義しています。

public struct Chunk
{
    public int length;
    public string chunkType;
    public byte[] chunkData;
    public uint crc;
}

public struct PngMetaData
{
    public int width;
    public int height;
    public byte bitDepth;
    public byte colorType;
    public byte compressionMethod;
    public byte filterMethod;
    public byte interlace;
}

これを利用して展開処理をしている部分を見ていきましょう。

public static (PngMetaData metaData, byte[]) Decompress(byte[] data)
{
    // ヘッダチャンクを取得
    Chunk ihdr = GetHeaderChunk(data);

    // ヘッダチャンクから幅、高さなどのメタデータを取得
    PngMetaData metaData = GetMetaData(ihdr);

    const int metaDataSize = 4 + 4 + 4;

    int index = PngSignatureSize + ihdr.length + metaDataSize;

    List<byte[]> pngData = new List<byte[]>();

    int totalSize = 0;

    // IDATチャンクを検索し見つかったものをすべてリストに追加する
    while (true)
    {
        if (data.Length < index) break;

        Chunk chunk = ParseChunk(data, index);

        if (chunk.chunkType == "IDAT")
        {
            pngData.Add(chunk.chunkData);
            totalSize += chunk.length;
        }

        if (chunk.chunkType == "IEND") break;

        index += chunk.length + metaDataSize;
    }

    // 最初の2byteがマジックバイトがあるため、それをスキップする
    // 参考:https://stackoverflow.com/questions/20850703/cant-inflate-with-c-sharp-using-deflatestream
    int skipCount = 2;

    byte[] pngBytes = new byte[totalSize - skipCount];
    Array.Copy(pngData[0], skipCount, pngBytes, 0, pngData[0].Length - skipCount);

    int pos = pngData[0].Length - skipCount;
    for (int i = 1; i < pngData.Count; ++i)
    {
        byte[] d = pngData[i];
        Array.Copy(d, 0, pngBytes, pos, d.Length);
        pos += d.Length;
    }

    // データ部分をDeflateStreamを使って解凍する
    using MemoryStream memoryStream = new MemoryStream(pngBytes);
    using MemoryStream writeMemoryStream = new MemoryStream();
    using DeflateStream deflateStream = new DeflateStream(memoryStream, CompressionMode.Decompress);

    deflateStream.CopyTo(writeMemoryStream);
    byte[] decompressed = writeMemoryStream.ToArray();

    return (metaData, decompressed);
}

データ内にマジックバイトが含まれているため削除が必要

上記コードのコメントにも記載していますが、最初、データを解凍しようとしたらエラーが出てうまく行きませんでした。色々調べた結果、以下の記事で言及があるように、2バイトのマジックバイトが含まれており、それを取り除いて解凍しないとエラーが出てしまうようです。

If you read my comment you will see that I encountered this problem 18 hours ago and although the answer to the problem is here in your answer it is not directly apparent. In your answer there is the variable set wantRfc1950Header = true and in your input stream the first two bytes are the RFC 1950 magic bytes 78 9c. The System.IO.Compression.DeflateStream expects a raw RFC 1951 stream that has these two bytes omitted. I imagine you should be able to use your initial example if you chop off these first two bytes before feeding it to the inflator.

On the downside it has taken me over 18 hours to find out that I need to remove two bytes of data. On the upside I am much more familiar with the internals of zlib and Huffman coding.

stackoverflow.com

コードにすると以下の部分ですね

// 最初の2byteがマジックバイトがあるため、それをスキップする
// 参考:https://stackoverflow.com/questions/20850703/cant-inflate-with-c-sharp-using-deflatestream
int skipCount = 2;

byte[] pngBytes = new byte[totalSize - skipCount];
Array.Copy(pngData[0], skipCount, pngBytes, 0, pngData[0].Length - skipCount);

フィルタリングを解く

前段まででデータの解凍ができました。いちおうこの時点でもRGBAのデータとして扱えるバイトの並びになっています。しかしPNGDeflate 圧縮が有効に働くように加工されており、そのままだと色がおかしなことになってしまいます。ということで、次はこの加工(フィルタリング)されたデータを復元する展開処理を見ていきます。

なお、展開に関しては以下の記事を参考にさせていただきました。

darkcrowcorvus.hatenablog.jp

記事から引用させてもらうと以下のようにフィルタリングされているデータが格納されています。

PNGファイルに収められる画像データは、zlibによって圧縮される前に、その圧縮効率を上げる目的で フィルタリング という事前処理が施される

PNGイメージをパースする際、zlib解凍を行った後 それを本来の画像データに戻すために、そのデータのフィルタリングを解く必要がある

フィルタリングの種類

フィルタリングにはいくつか種類があります。ざっくり言うと、「どのピクセルを参考にして復元するか」の種別です。以下にその種類と意味をまとめます。

番号 フィルタ名 説明
0 None フィルタなし。そのまま色データとして扱う
1 Sub 隣接する左ピクセルの色との差分
2 Up 隣接する上ピクセルの色との差分
3 Average 左と上のピクセルの平均色との差分
4 Paeth 左、上、左上のピクセルのうち次回出現しそうな色との差分

このフィルタリングが意味するところは、現在処理しているピクセルをどう復元すればいいかを示すものです。

どういう情報になってるのかについては以下のサイトを参考にさせていただきました。

www.webtech.co.jp

説明を引用させていただくと、

例えば、このような10個の数値が並んでいたとします。それぞれの数値は画像の各画素の「色の値」を表わしていると思ってください。

データの意味
これらの数値を、全部覚えなくてはならなかったら、どうしますか? 10個もあると、暗記するのはちょっと大変そうですね。

でも良く見るとこれ、「左から順に、1 ずつ増えている」ことに気がつきます。

だから、こう書き換えてみたらどうでしょう。

加工

数値そのものではなく、差分を取ってその数値に置き換えてみました。また、この数値が「左隣との差分」であるというメモも書き添えます。

数値は10個のまま変わりませんが、急にスッキリして、なんだかとても覚えやすそうになりましたね。

これが「フィルタ」により加工した例です。

これをものすごくざっくりまとめると、

  • 左(や上など)のピクセルの情報を応用してデータを圧縮
  • どういう差分方式かの情報(フィルタタイプ)を1行ごとに追加する

というデータに変換することをフィルタリングと呼んでいるわけですね。

そして上の表にあるように、このフィルタタイプに応じて復元するピクセルの色の計算方法が変わります。

ひとつ例を上げましょう。

まず、以下の画像をPNG化する例を考えます。

f:id:edo_m18:20220220105343p:plain

これの赤枠に注目して見てみます。

f:id:edo_m18:20220220105140p:plain

以下に示すように、画像の1行に着目し、それを計算していきます。その際、「どう計算したのか」を示す値を行の先頭に付け加えます。

※ 以下の画像はαなしのRGB24bitの例です。が、計算方法はその他のカラータイプも同様です。

f:id:edo_m18:20220220155122p:plain

元のデータの並びが上段、それを計算したのが下段です。上記例では左のピクセルからの差分を取ってそれを保持しています。つまり計算自体は右から行うわけですね。そしてFilter type:1と書かれているのが、その行がどのタイプの計算になっているかを示しています。

つまり、画像に対して1行ごとに処理を行い、その処理のタイプを行の頭のに設定します。言い換えるとこれらの手順の逆処理をしていけば元のピクセルデータを復元することができます。(ちなみに元の色が100%再現できます。これが可逆圧縮と言われている所以ですね)

フィルタリングを解く実装

構造およびそれらの意味について見てきました。あとはこれを参考にして実際に展開処理を実装していきます。ということで、展開しているコードを見てみます。

private static Texture2D ParseAsRGBA(byte[] rawData, SynchronizationContext unityContext)
{
    // ファイルから読み込んだ生のデータを、前段の処理で解凍する
    (PngMetaData metaData, byte[] data) = Decompress(rawData);

    // 展開したピクセルの情報を格納するための構造体の配列を確保
    Pixel32[] pixels = new Pixel32[metaData.width * metaData.height];

    // 1ピクセルのbitのサイズを計算
    byte bitsPerPixel = GetBitsPerPixel(metaData.colorType, metaData.bitDepth);

    // 1行あたりのバイトサイズを計算
    int rowSize = 1 + (bitsPerPixel * metaData.width) / 8;

    // 何バイトずつデータが並んでいるかを計算
    int stride = bitsPerPixel / 8;

    for (int h = 0; h < metaData.height; ++h)
    {
        int idx = rowSize * h;

        // 該当行のフィルタリングタイプを取得
        byte filterType = data[idx];

        int startIndex = idx + 1;

        switch (filterType)
        {
            case 0:
                break;

            case 1:
                UnsafeExpand1(data, startIndex, stride, h, pixels, metaData);
                break;

            case 2:
                UnsafeExpand2(data, startIndex, stride, h, pixels, metaData);
                break;

            case 3:
                UnsafeExpand3(data, startIndex, stride, h, pixels, metaData);
                break;

            case 4:
                UnsafeExpand4(data, startIndex, stride, h, pixels, metaData);
                break;
        }
    }

    Texture2D texture = null;

    // --------------------------------------------
    // ※ テクスチャの生成処理は後述
    // --------------------------------------------

    return texture;
}

PNGデータのフィルタリングの都合上、1行ずつ処理をしていく必要があります。 (※ ほとんどの処理が左隣のピクセルデータに依存しているため)

そのため、まずは行単位で処理を行うようにループで処理をしています。そして各行の最初のバイトに、どのタイプでフィルタリングを施したのかの情報が入っています。それを抜き出しているのが以下の部分です。

byte filterType = data[idx];

データ配列はこのフィルタリングタイプと画像の幅 x 要素数分(RGBAなら4要素=4バイト)を足したサイズを1行分として、それが画像の高さ分並んでいます。なので、フィルタリングタイプに応じて処理を分け、その中で1行分の展開処理を行っていきます。

【フィルタタイプ 0 - None】無加工

フィルタタイプ 0None、つまり無加工です。そのため各ピクセルのデータがそのまま格納されています。

【フィルタタイプ 1 - Sub】左隣からの差分から展開

フィルタリングタイプが 1 の場合は左隣のピクセルからの差分データが並びます。値の算出は以下のようにして左隣のデータを参考にして求めて格納します。

f:id:edo_m18:20220220105115p:plain

復元する場合はその逆を行えばいので、以下の式で求めることができます。

ピクセルの値 + 左隣のピクセルの値)% 256

その式を元に復元しているコードは以下になります。

private static unsafe void UnsafeExpand1(byte[] data, int startIndex, int stride, int y, Pixel32[] pixels, PngMetaData metaData)
{
    // データサイズを超えていないかチェック
    if (data.Length < startIndex + (metaData.width * stride))
    {
        throw new IndexOutOfRangeException("Index out of range.");
    }

    // 計算効率化のためポインタを利用して計算
    fixed (Pixel32* pixpin = pixels)
    fixed (byte* pin = data)
    {
        // バイト配列のポインタアドレスをスタート地点まで移動
        byte* p = pin + startIndex;

        // 展開後のデータを格納する構造体配列も同様にポインタ化して位置を移動
        // 注意点として、データが「画像の下から」格納されているため配列の後ろから格納している点に注意。
        Pixel32* pixp = pixpin + (metaData.width * (metaData.height - 1 - y));

        Pixel32 left = Pixel32.Zero;
        Pixel32 current = default;

        for (int x = 0; x < metaData.width; ++x)
        {
            // ポインタをuint型にキャストして構造体(RGBAの4バイト=uintと同じサイズ)に効率的に値を格納
            *(uint*)&current = *(uint*)p;

            // 左隣から展開する処理を実行
            left = Pixel32.CalculateFloor(current, left);

            // ポインタを要素数分(RGBAの4バイト)進める
            p += stride;

            // 計算結果をポインタ経由で格納し、位置をひとつ分進める
            *pixp = left;
            ++pixp;
        }
    }
}

public static Pixel32 CalculateFloor(Pixel32 left, Pixel32 right)
{
    byte r = (byte)((left.r + right.r) % 256);
    byte g = (byte)((left.g + right.g) % 256);
    byte b = (byte)((left.b + right.b) % 256);
    byte a = (byte)((left.a + right.a) % 256);

    return new Pixel32(r, g, b, a);
}

【フィルタタイプ 2 - Up】上のピクセルの差分から展開

フィルタタイプ 2 は、1 の左からの差分の計算をそのまま上からのピクセルの差分に意味を置き換えたものになります。図にすると以下のようになります。(基本的に左隣のものと対象となるピクセルが違うだけで処理そのものは同じです)

f:id:edo_m18:20220220113042p:plain

復元する場合は 1 と同様、その逆を行えばいので、以下の式で求めることができます。

ピクセルの値 + 上のピクセルの値)% 256

その式を元に復元しているコードは以下になります。

private static unsafe void UnsafeExpand2(byte[] data, int startIndex, int stride, int y, Pixel32[] pixels, PngMetaData metaData)
{
    if (data.Length < startIndex + (metaData.width * stride))
    {
        throw new IndexOutOfRangeException("Index out of range.");
    }

    fixed (Pixel32* pixpin = pixels)
    fixed (byte* pin = data)
    {
        byte* p = pin + startIndex;
        Pixel32* pixp = pixpin + (metaData.width * (metaData.height - 1 - y));

        Pixel32 up = Pixel32.Zero;
        Pixel32 current = default;

        int upStride = metaData.width;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)p;

            if (y == 0)
            {
                up = Pixel32.Zero;
            }
            else
            {
                *(uint*)&up = *(uint*)(pixp + upStride);
            }

            up = Pixel32.CalculateFloor(current, up);

            p += stride;

            *pixp = up;
            ++pixp;
        }
    }
}

【フィルタタイプ 3 - Average】左と上のピクセルの平均から展開

フィルタタイプ 3 はある意味、 12 の合せ技のような方法です。左と上のピクセルを求め、その平均を計算したものを結果として採用します。そして最後に、12 同様の計算を行います。言い換えると、1 では左の値を、2 では上の値を、そして 3 では平均の値を元に計算を行う、ということです。

f:id:edo_m18:20220220140225p:plain

これを復元するには

ピクセルの値 + 左と上のピクセル値の平均) % 256

その式を元に復元しているコードは以下になります。

private static unsafe void UnsafeExpand3(byte[] data, int startIndex, int stride, int y, Pixel32[] pixels, PngMetaData metaData)
{
    if (data.Length < startIndex + (metaData.width * stride))
    {
        throw new IndexOutOfRangeException("Index out of range.");
    }

    fixed (Pixel32* pixpin = pixels)
    fixed (byte* pin = data)
    {
        byte* p = pin + startIndex;
        Pixel32* pixp = pixpin + (metaData.width * (metaData.height - 1 - y));

        Pixel32 up = Pixel32.Zero;
        Pixel32 left = Pixel32.Zero;
        Pixel32 current = default;

        int upStride = metaData.width;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)p;

            if (y == 0)
            {
                up = Pixel32.Zero;
            }
            else
            {
                *(uint*)&up = *(uint*)(pixp + upStride);
            }

            left = Pixel32.CalculateAverage(current, left, up);

            p += stride;

            *pixp = left;
            ++pixp;
        }
    }
}

public static Pixel32 CalculateAverage(Pixel32 a, Pixel32 b, Pixel32 c)
{
    int ar = Average(b.r, c.r);
    int ag = Average(b.g, c.g);
    int ab = Average(b.b, c.b);
    int aa = Average(b.a, c.a);

    return CalculateFloor(a, new Pixel32((byte)ar, (byte)ag, (byte)ab, (byte)aa));
}

private static int Average(int left, int up)
{
    return (left + up) / 2;
}

平均計算部分を見てもらうと気づくと思いますが、ただ平均を取るだけではなく、平均を取った値との差分を求めている点に注意が必要です。PNGのデータは常になにかとの差分の結果だ、ということを念頭に入れておくと理解しやすくなると思います。

【フィルタタイプ 4 - Paeth】左・上・左上から推測して展開

参考にさせていただいた記事から引用すると、以下のアルゴリズムで値が決定しているもののようです。

Paethアルゴリズムは、左、上、左上の 3つの隣接するピクセル値から、「この位置に来るであろうピクセル値が、上記 3つのピクセル値のうち、どれと一番近くなりそうか」を予測するために利用される。Alan W. Paethさんが考案した

また計算式も引用させていただくと、以下を満たす位置にあるピクセルの値を採用します。

int PaethPredictor(int a, int b, int c)
{
    // +--------+
    // | c | b |
    // +---+---+
    // | a | ? |
    // +---+---+
    int p = a + b - c;

    // pa = |b - c|   横向きの値の変わり具合
    // pb = |a - c|   縦向きの値の変わり具合
    // pc = |b-c + a-c| ↑ふたつの合計
    int pa = abs(p - a);    
    int pb = abs(p - b);    
    int pc = abs(p - c);    

    // 横向きのほうがなだらかな値の変化 → 左
    if (pa <= pb && pa <= pc)
        return a;

    // 縦向きのほうがなだらかな値の変化 → 上
    if (pb <= pc)
        return b;
        
    // 縦横それぞれ正反対に値が変化するため中間色を選択 → 左上        
    return c;
}

これの復元は以下のように行います。

ピクセルの値 + Paethアルゴリズムによって求まったピクセルの値) % 256

その式を元に復元しているコードは以下になります。

private static unsafe void UnsafeExpand4(byte[] data, int startIndex, int stride, int y, Pixel32[] pixels, PngMetaData metaData)
{
    if (data.Length < startIndex + (metaData.width * stride))
    {
        throw new IndexOutOfRangeException("Index out of range.");
    }

    fixed (Pixel32* pixpin = pixels)
    fixed (byte* pin = data)
    {
        byte* p = pin + startIndex;
        Pixel32* pixp = pixpin + (metaData.width * (metaData.height - 1 - y));

        Pixel32 up = Pixel32.Zero;
        Pixel32 left = Pixel32.Zero;
        Pixel32 leftUp = Pixel32.Zero;
        Pixel32 current = default;

        int upStride = metaData.width;

        for (int x = 0; x < metaData.width; ++x)
        {
            *(uint*)&current = *(uint*)p;

            if (y == 0)
            {
                up = Pixel32.Zero;
            }
            else
            {
                *(uint*)&up = *(uint*)(pixp + upStride);
            }

            if (y == 0 || x == 0)
            {
                leftUp = Pixel32.Zero;
            }
            else
            {
                *(uint*)&leftUp = *(uint*)(pixp + upStride - 1);
            }

            left = Pixel32.CalculatePaeth(left, up, leftUp, current);

            p += stride;

            *pixp = left;
            ++pixp;
        }
    }
}

public static Pixel32 CalculatePaeth(Pixel32 a, Pixel32 b, Pixel32 c, Pixel32 current)
{
    int cr = PaethPredictor(a.r, b.r, c.r);
    int cg = PaethPredictor(a.g, b.g, c.g);
    int cb = PaethPredictor(a.b, b.b, c.b);
    int ca = PaethPredictor(a.a, b.a, c.a);

    return CalculateFloor(current, new Pixel32((byte)cr, (byte)cg, (byte)cb, (byte)ca));
}

private static int PaethPredictor(int a, int b, int c)
{
    int p = a + b - c;
    int pa = Mathf.Abs(p - a);
    int pb = Mathf.Abs(p - b);
    int pc = Mathf.Abs(p - c);

    if (pa <= pb && pa <= pc)
    {
        return a;
    }

    if (pb <= pc)
    {
        return b;
    }

    return c;
}

展開処理の説明は以上です。最後に、この計算で展開されたデータをテクスチャのデータとして渡すことができれば完成です。

構造体の配列を直にバイト配列として読み込む(ポインタからテクスチャを生成)

展開処理の最後はデータを実際のテクスチャデータとして利用することです。ここでは、効率的にテクスチャにデータを渡す方法を見ていきます。

展開処理で見てきたように、計算を簡単にするために Pixel32 という構造体を作りました。計算結果の配列もこの Pixel32 が並んだものになっています。しかし当然、独自で作成した構造体なのでこれをそのままテクスチャのデータとして読み込ませることはできません。

しかし、Texture2D.LoadRawTextureDataポインタを受け取ることができ、メソッド名からも分かる通りテクスチャのピクセルを表すバイト配列を期待しています。なので、Pixel32 の配列をバイト配列として認識させれば引数に渡すことができます。

構造体はシンプルに、フィールドを順番に並べたものになっています。つまり RGBA の4バイトが順番に並び、それが配列になっているので実質Raw dataと見なすことができるわけです。

前置きが長くなりましたがやることはシンプルです。Pixel32 の配列をポインタに変換して、それを引数に渡すことで簡単に実現することができます。百聞は一見にしかずということでコードを見てみましょう。

Texture2D texture = null;
unityContext.Post(s =>
{
    texture = new Texture2D(metaData.width, metaData.height, TextureFormat.RGBA32, false);

    // GCの対象にならないようにハンドルを取得
    GCHandle handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);

    try
    {
        IntPtr pointer = handle.AddrOfPinnedObject();
        texture.LoadRawTextureData(pointer, metaData.width * metaData.height * 4);
    }
    finally
    {
        if (handle.IsAllocated)
        {
            // GCされるように解放
            handle.Free();
        }
    }

    texture.Apply();
}, null);

while (texture == null)
{
    Thread.Sleep(16);
}

return texture;

pixelsPixel32 構造体の配列です。これの GCHandle を取得し、 AddrOfPinnedObject() メソッドから配列の先頭アドレスを得ます。戻り値は IntPtr なのでこれをそのまま LoadRawTextureData() メソッドに渡すことで読み込むことができます。第2引数はデータのサイズです。

最後に

今回は学習目的での実装だったのでそこまで最適化をしていません。そのため Texture2D.LoadImage で読み込む処理に比べるとだいぶ遅いです。(大体10倍くらい遅い)

ただUnity APIを利用していないのでスレッドで実行できますし、前回の記事のようにテキストチャンクにデータを仕込んでそれを取り出す、ということもできます。ちょっとした付与データ込みの画像データを保存する、とかであれば現状でも用途があるのかなと思っています。

最初に実装したのは配列をそのまま利用していたためさらに処理が重かったのですが、ポインタ経由にすることで3倍くらいは速くなりました。ポインタを利用しての最適化は他の場所でも使えるので覚えておいて損はないかなと思います。

いちおう、特定用途ではありますが実用に耐えうるものとしてより最適化をしていこうと思っています。(できたらBurst対応とかもしたい)

もし最適化が出来て実用に耐えうるものになったらそれも記事に書こうと思います。