koturnの日記

普通の人です.ブログ上のコードはコピペ自由です.

PNGファイル再圧縮ツールを改良した

TL;DR

2020-12-07にも記事を書いたツールだが,当時より機能追加を行ったため,再度記事を書くことにした. 大きくは下記4点が追加となった.

  1. VRMファイルを処理可能になった
  2. 元のPNGファイルの画像フォーマットをそのままにしておくオプションの追加
  3. IDATのデータ部のサイズを指定可能にした
  4. 全チャンクの保存オプションの追加

リポジトリと実行バイナリ配布場所は下記の通り.

f:id:koturn:20210707033537p:plain
再圧縮前後の様子

1. VRMファイルを処理可能になった

VRMファイルをパースし,内部にPNGファイルデータが含まれる場合,再圧縮を行えるようにした. VRMファイル自体は先頭にjsonがあり,その後にバイナリデータが続き,json内のオフセットと長さ情報からデータを切り出すことが出来る形式のファイルとなっている.

なので,

  1. ファイル先頭のjsonをパース
  2. PNGデータを取り出す
  3. PNGデータを再圧縮
  4. 再圧縮したPNGデータをバイナリデータ部に書き戻す
  5. json全体のオフセットと長さ情報をすべて更新
  6. ファイルとして書き出す

という処理でVRMファイル内のPNGファイルデータの再圧縮を実現している.

2. 元のPNGファイルのピクセルデータフォーマットをそのままにしておくオプションの追加

前の記事にも書いていることであるが,再度書く.

これはzopflipng コマンド では存在している --keep-color-type オプションを使用できるようにしたものである. Google公式のリポジトリのものでは,zopflipng.dllからは --keep-color-type に相当する指定を行うことが出来ないが,クローンしたリポジトリではこの指定が可能になるような修正を行っている.

zopflipngは画像データの展開結果が変化しない(ビットマップデータへ展開した結果,元のものと全画素データを比較しても差がない)ならば,ピクセルデータフォーマットを変更することがある. 例えば,

  1. アルファ値が全て255であるARGB32bit形式をRGB24bit形式に変換
  2. 全体として256色以内の色数しか使われていない画像を8bitインデックス画像に変換
  3. 2とは逆に1bpp(2色のインデックス画像,1byteあたり8ピクセル)をRGB24bit画像変換
    • PLTE チャンクの削減によりデータサイズが小さくなることがある

といった変換を行うことがあり得る. 基本的に画像データの見た目に差が生じるわけではないので,問題が生じることはないが,3. の変換が生じた場合,インデックス画像しか取り扱えないアプリケーションで画像データを開けなくなる(特にEdge等のドット絵ツール). そのため,画像データフォーマットを保持する機能は欠かせない.

3. IDATのデータ部のサイズを指定可能にした

zopflipngは少しでもファイルサイズを削減するために,PNGファイルのIDATチャンクを1つにまとめる(IDATチャンクのデータ部ノサイズは自由であるため,1まとめにすることも分割することも可能.ただし,IDATチャンク1つにつき,データ長,チャンク種別,CRC-32の分のサイズオーバーヘッドが12 Bytesある). しかし,人によってはIDATチャンクをデータ部のサイズが最大8192 Bytesになるように分割したい,あるいはWindowsのpaint.exeのように65535 Bytes近くになるように分割したいという人もいるかもしれない.

そのため,PNGデータの再圧縮完了後にIDATチャンクを分割する処理を行うことができるように,オプション --idat-size を追加した.

このオプションを指定しなかったり,0やマイナスの値を指定すると,今まで通りIDATの分割処理を行わないが,例えば --idat-size=8192 と指定するとIDATチャンクのデータ部がそれぞれ最大8192 BytesとなるようにIDATチャンクを分割する.

IDATチャンクを小さくすることで,PNGデコーダの読み取り時の一時バッファのサイズを小さく保つことが出来るようになるが,これが速度面に寄与するかどうかは不明である. PNGの仕様としては下記の記述があるだけで,C言語的な時代を感じる.

Multiple IDAT chunks are allowed so that encoders can work in a fixed amount of memory; typically the chunk size will correspond to the encoder's buffer size.

VRChatやclusterの画像データ,またはGIMPといったアプリケーションが生成するPNGファイルのIDATチャンクのデータ部のサイズとしては 8192 Bytes が採用されている. これはおそらくlibpngのデフォルトのIDATのサイズなのではないかと思う.

4. 全チャンクの保存オプションの追加

--keep-all-chunks というオプションを指定すると,すべてのチャンクを保持したままにするようにした. このオプションは --keep-chunks=acTL,bKGD,cHRM,eXIf,fcTL,fdAT,gAMA,hIST,iCCP,iTXt,pHYs,sBIT,sPLT,sRGB,tEXt,tIME,zTXt を指定したのと同じ効果である.

zopflipngはファイルサイズを小さくすることを最優先にしているので,必須ではないチャンクは基本的に除去する. 以前のものでも保持したいチャンクは --keep-chunks= で指定することで残すことが出来たが,雑にメタデータチャンクを残したい場合にでも1つ1つチャンク名を指定しなければならないのは面倒であったため,このオプションを追加した.

裏で改善したところ

以下の項目は特に機能追加ではなく,ほとんどが自己満足的な改良である.

.NET 5への移行

最初は.NET Framework 4.8で作成していたが,最新のC#と標準ライブラリ(SpanSIMDAPI)を利用したかったので,.NET 5に移行した.

.NET 5では,.NET 5依存のバイナリをそのままリリースすることもできるし,Self-Containedという実行環境にて.NET 5が不要になるバイナリの作成を行うこともできる(その分ファイルサイズがかなり大きくなるが).

再圧縮結果のアンマネージドメモリをそのまま使うようにした

以前は koturn/ZopfliSharp にて,zopflipng.dll の関数呼び出しより得たアンマネージドメモリはすぐさま同サイズのマネージドメモリを確保し,そこにコピーし,そのマネージドメモリを返却するようにしていた

よく知られているように,.NETでは85KB以上のメモリ確保を行うと,LOHに確保されてしまう. 僕がよく再圧縮処理対象にするVRChatやclusterのPNGファイルはサイズが大きいため,再圧縮結果が85KB以内に収まるということはあまりない(VRChatの画像の多くは1.5MB,clusterの画像は1MB弱程度になる). そのため,このメモリアロケーションが気になっていた.

そこで,アンマネージドメモリを SafeBuffer として返却するメソッドをkoturn/ZopfliSharp追加し,それを利用するようにした. この変更により,処理時間が劇的に短くなるわけではないが,P/Invoke用のライブラリを作った以上はこのメソッドは欲しかったのと,趣味プロダクトであるので,微々たる処理の最適化を行いたかった.

再圧縮結果がオリジナルのものより悪ければ,オリジナルの方をそのまま使うという処理のために byte 配列と SafeBuffer を混在して扱う必要が生じたが,前述の通り,.NET 5へ移行したので,Span を利用し,統一的に扱うことによってコードをシンプルに保った. SafeBuffer から Span を生成するのにあたって下記のようにした.

private static unsafe Span<byte> CreateSpan(SafeBuffer sb)
{
    return new Span(sb.DangerousGetHandle(), (int)sb.ByteLength);
}

また,再圧縮のVerificationに必要な Bitmap インスタンスの生成にPNG画像データの Stream が必要になるが,UnmanagedMemoryStream を利用すれば,byte 配列に対する MemoryStream と同じノリで扱うことができる.

private static Bitmap CreateBitmap(SafeBuffer sb)
{
    using ums = new UnmanagedMemoryStream(sb, 0, (long)sb.ByteLength);
    return (Bitmap)Image.FromStream(ums);
}

SafeBufferDispose() メソッドの呼び出しで,中身のアンマネージドメモリを解放することができるので,using句を用いればメモリ解放漏れは無くなる.

using (var sb = Zopfli.OptimizePngUnmanaged(data, 0, data.Length))
{
    // 処理
}

画像比較ログの改善

zopflipngによる再圧縮のVerificationとして,元の画像と比較する機能がある. 以前は画像フォーマットが異なる場合,画像に差があることしかわからないログであったが,どのように画像フォーマットが変化したかわかるようにログ出力するようにした.

参考