チャンクを直接読み込んでPNG画像のファイルフォーマットを理解する

PNG画像のデータ構造を簡単にまとめて、Unityを使ってチャンクの読み込みを行う方法についてまとめます。

Unity2020.1(動作確認はUnityで行っています)

PNGファイルの構成

まずPNGファイルの構成について学びます。
PNGファイルはまずファイルの先頭に8バイトのシグネチャと呼ばれるバイト列が存在します。
これは「このファイルがPNGファイルであること」を示すもので、固定の値が入ります。
10進数だと「137, 80, 78, 71, 13, 10, 26, 10」です。

シグネチャの次には、チャンクというデータの集まりが複数連続して格納されています。
各チャンクはそれぞれ「実際の色データ」「透明度」「ファイルの終端」などを表します。
要するに情報の種類ごとにチャンクという括りでデータをまとめているということです。

各チャンクは「サイズ」「タイプ」「データ」「CRC」の4つの情報を持ちます。
最もシンプルな構成は以下のようにヘッダ情報を持つIHDRチャンク、色データを持つIDATチャンク、ファイルの終端を表すIENDチャンクから成ります。

f:id:halya_11:20200816092842p:plain
PNGファイルの構成

チャンクを読み込む

PNGの大まかな仕様がわかったところで、実際にプログラムで上述のヘッダやチャンクの情報を読み込んでみます。
まずソースコード全文を記載します。

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;

// チャンクを表す
[Serializable]
public class Chunk
{
    [SerializeField] private string _type;
    [SerializeField] private byte[] _data;
    [SerializeField] private uint _crc;
    
    public Chunk(string type, byte[] data, uint crc)
    {
        _type = type;
        _data = data;
        _crc = crc;
    }
}

[CreateAssetMenu(menuName = "PngData")]
public class PngData : ScriptableObject
{
    [SerializeField] private byte[] _signature;
    [SerializeField] private List<Chunk> _chunks = new List<Chunk>();
    
    public void SetData(string fileName)
    {
        using (var stream = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read))
        {
            SetData(stream);
        }
    }

    public void SetData(Stream stream)
    {
        _chunks.Clear();
        
        // シグネチャを読み込み
        _signature = new byte[8];
        stream.Seek(0, SeekOrigin.Begin);
        stream.Read(_signature, 0, _signature.Length);

        while (stream.Position != stream.Length)
        {
            // Size
            var sizeBytes = new byte[4];
            stream.Read(sizeBytes, 0, 4);
            if (BitConverter.IsLittleEndian)
            {
                // ビッグエンディアンに変換
                Array.Reverse(sizeBytes);
            }
            var size = BitConverter.ToUInt32(sizeBytes, 0);

            // Type
            var typeBytes = new byte[4];
            stream.Read(typeBytes, 0, 4);
            var type = Encoding.ASCII.GetString(typeBytes);

            // Data
            var data = new byte[size];
            stream.Read(data, 0, (int)size);

            // CRC
            var crcBytes = new byte[4];
            stream.Read(crcBytes, 0, 4);
            if (BitConverter.IsLittleEndian)
            {
                // ビッグエンディアンに変換
                Array.Reverse(sizeBytes);
            }
            var crc = BitConverter.ToUInt32(crcBytes, 0);

            var chunk = new Chunk(type, data, crc);
            _chunks.Add(chunk);
        }
    }
}

[CustomEditor(typeof(PngData))]
public class PngDataInspector : Editor
{
    [SerializeField] private string _path;

    public override void OnInspectorGUI()
    {
        var pngData = (PngData) target;
        _path = EditorGUILayout.TextField("Png Path", _path);

        GUI.enabled = !string.IsNullOrEmpty(_path);
        if (GUILayout.Button("Set Data"))
        {
            pngData.SetData(_path);
        }

        GUI.enabled = false;
        base.OnInspectorGUI();
        GUI.enabled = true;
    }
}

Assets > Create > PngDataを選択すると以下のようなInspector表示のScriptableObjectが生成されます。

f:id:halya_11:20200816093007p:plain
ScriptableObject

このPng Pathに画像のパスを指定してSet Dataをクリックすることでチャンクなどの情報がシリアライズされる仕組みです。
実際に適当な画像の情報を読み込んでみると以下のようになります。

f:id:halya_11:20200816085446p:plain
読み込み結果

シグネチャと、IHDR、IDAT、IENDという三つのチャンクが存在することが確認できました。

チャンクを書き換える

さてこのようにPNGファイルの中身は比較的簡単に読み込むことができます。
これに対してチャンクを書き込む場合には、CRCを計算したり若干面倒な処理が増えます。
本記事はフォーマットを学ぶことが目的であるため書き込みまでは行いませんが、
もし独自のチャンクを定義するなど書き込む必要がある場合には以下のようなライブラリを使用すると便利です(自分で書いてもそんなに大変ではなさそう)。

github.com

参考

darkcrowcorvus.hatenablog.jp

news.mynavi.jp

www.setsuki.com