PNG軽量化の減色と圧縮について

はじめまして、クライアント基盤のいまやです。

はじめに

PNG 画像を軽量化する話題になると「減色」と「可逆圧縮」(以下、単に圧縮と表記)を混同している例をよく見かけます。
この記事では圧縮と減色について簡単に説明し、PNG の軽量化とは何をするのか具体的に書き、これらの混同を防ぐのを目的とします。
軽量化のそれぞれの仕組みについても説明しますが、この部分は軽量化について軽く知っておきたいだけの方は読み飛ばしても問題ありません。
また、この記事では説明のためいくつかの要素を意図的に省略しています。

PNG の画像データ形式

まず、PNG 形式の画像はどのようにして各ピクセルの色を保持しているのか説明します。

RGB形式とIndexedColour形式の比較

PNG では大きく分けて2つの形式で画像の色情報を表現することができます。「RGB形式」と「Indexed Colour 形式」です。("Colour" なのは仕様の記述に従っているためです)
RGB 形式では、1ピクセルごとに赤(Red)、緑(Green)、青(Blue)の値をそれぞれ記述します。
それに対し Indexed Colour 形式では、画像の中で使われる色をパレットとよばれるテーブルに予め記述し、ピクセルはパレットの何番目の色を使うか記述します。
また、それぞれの行には先頭にフィルタとよばれるものを表すデータがつきますが、これは後述します。

そして、最後にこれらのデータを ZLIB という圧縮形式で圧縮したものが PNG の画像部分のデータ形式となります。

PNG 画像の軽量化

PNG の画像部分のデータについての軽量化というのは、大きく2つに分けられます。(無駄なチャンクの削除などは省略します)

  • RGB 形式から Indexed Colour 形式に変換する
  • ZLIB の圧縮効率を向上させる

RGB 形式から Indexed Colour 形式への変換と ZLIB 圧縮の部分は別の処理であるため、この2つを組み合わせる事で PNG の軽量化ができます。

減色によるデータサイズ削減

減色とは、文字通り色を減らすことです。
RGB 形式では、それぞれ 8bit で表現すると 1 ピクセルあたり 24bit 必要です。24bit というとピンとこないかもしれませんが HTML や CSS などで指定する #ffffff 形式の色指定です。

これを Indexed Colour 形式に変換することでデータサイズが削減されることを説明します。

まずは、画像で使用されている色を以下のようなテーブルにします。

パレット番号 Red Green Blue
0 0 0 0
1 127 127 127
2 255 255 255
... ... ... ...

このテーブルの番号は 1 Byte になっているため、0-255 の 256 個しか登録できません。そのため、画像で使用されている色が 256 個より多い場合は、なんとかして 256 個にしなくてはいけません。
この「なんとかして 256 色にする」というのが減色処理で、なるべく元の画像からの変化を分からないようにしながら色を減らしていくためのアルゴリズム実装です。(この記事では減色アルゴリズムについての説明は省略します。)

テーブルを作成したら、画像のそれぞれのピクセルを RGB 形式からテーブルの何番目の色を使うかに置き換えます。

RGB形式からIndexedColour形式に変換した際の圧縮前データサイズの減少

上図のように、1 ピクセルあたり 24bit 必要だった画像が 1 ピクセルあたり 8bit になったので、データサイズは大体 1/3 になります。
(パレットのデータに最大 3 Byte * 256 = 768 Byte 必要とか、同じように圧縮されないとか、細かい部分をみると実際には単純に1/3にはなりません)

圧縮効率最適化

画像では、形式にかかわらず 1 ピクセルごとにどの色かという情報をもっていますが、これをそのまま扱うのは効率がよくありません。
そこで、PNG ではよく現れるデータをアルゴリズムに従ってまとめて表現することでサイズの削減を行っています。

例えば「リンゴ、リンゴ、リンゴ」と言うよりも「リンゴ3つ」といったほうが簡潔です。
これは単純化した例ですが、圧縮というのはこのようにデータが偏っていた場合にまとめる処理のことを言います。

    • 「リンゴ、バナナ、パイナップル、グレープフルーツ、キウイ、ブドウ」→そのまま
    • 「リンゴ、リンゴ、リンゴ、バナナ、バナナ、バナナ→「リンゴ3つ、バナナ3つ」

PNG には行ごとにフィルタというデータを偏らせるための仕組みもあります。これは前のデータと比べて差や平均などを取ってデータを偏らせる仕組みです。
ただし、Indexed Colour 形式のときはフィルタでは何もしない方が圧縮効率がよくなることが多いようです。

PNG の圧縮形式

PNG では圧縮形式として ZLIB というものを採用しています。ZLIB では圧縮処理に Deflate という LZSS とハフマン符号という2つのアルゴリズムを組み合わせたものを使用しています。
分かりにくいかもしれませんが、以下のように実際に圧縮する処理が Deflate でそれに補助データをつけてコンテナ化しているのが ZLIB です。

では、それぞれの圧縮アルゴリズムについて簡単に説明していきます。

LZSS

繰り返し現れるデータを「何文字もどって何文字コピーするか」で表現するコピー&ペーストによる圧縮です。
たとえば、「かえるぴょこぴょこみぴょこぴょこ」というデータがあったとします。

最初の「ぴょこぴょこ」は「ぴょこ」が重なっているので「ぴょこ[3文字戻って,3文字コピー]」という形で表します。(以後 [3,3] のように表記)
同様に、後の「ぴょこぴょこ」についても [7,6] となるので、この例では「かえるぴょこ[3,3]み[7,6]」となります。
実際のデータでは文字単位ではなくバイト単位で行うのですが、その場合 [3,3] とか [7,6] をどうやって表現するかが問題になります。
なぜ問題かというと、0-255 しか入らないところに新しく LZSS の符号を新しく追加することになるので、単純に考えた場合、1バイトのデータに必要な情報量を8ビットから増やして書かなくてはいけません。

そこで LZSS のデータに対して、これから説明するハフマン符号を組み合わせることで効率よくデータを格納しています。

ハフマン符号

頻繁に出現する文字は短く表記して、あまりでない文字は長く表記ことで小さくしようという圧縮です。
このアルゴリズムはデータをビット単位で扱うため、少し分かりにくいかもしれません。

たとえば、「あかまきがみあおまきがみきまきがみ」というデータがあったとします。文字の出現頻度をみると以下のようになります。

  • き: 4
  • ま: 3
  • が: 3
  • み: 3
  • あ: 2
  • か: 1
  • お: 1

これに、出現頻度の多い順に符号を割り当てていきます。

ハフマン符号の符号割り当て

以下のような割り当て表が出来るので、それを圧縮データとは別に添付します。

  • き: 00
  • ま: 01
  • が: 100
  • み: 101
  • あ: 110
  • か: 1110
  • お: 1111

この例では7文字しか使用していないので、元のデータを 3bit で表していたとすると符号化前のデータは 51 bit 必要になります。
それに対し、ハフマン符号では 46bit になります。
このような偏りがあまりなく小さなデータでは分かりにくいですが、実際のデータではもっと偏りが大きく、元のデータも大きいので効果的になります。

また、ハフマン符号では上記のように文字だけでなく、LZSS の符号などに対しても割り当てることができます。
これにより、LZSS で符号化したデータも効率よく扱う事ができるようになります。

ブロック分割

Deflate ではブロックという単位で圧縮する範囲を分割することができます。(ブロックの大きさは自由)
これをうまく使う事で、ハフマン符号による圧縮をより効率的にすることができます。

最適化

ここまで、圧縮アルゴリズムについて簡単に説明をしましたが、一つ注意しておいて欲しいことがあります。
それは「こうすればかならず最高の圧縮効率になる」という決まった方法がないということです。
例えば、LZSS でより長いコピーをするよりも、ハフマン符号で効率がよくなるように選択した方が良いケースなど、状況によって最善の選択というのが異なります。

決まった方法がないので様々なパターンを試行し、より効率的に圧縮できたものを採用するのが最適化となります。
PNG の最適化ソフトウェアでは複雑な組み合わせを網羅的に試行し、一番小さくなるものを採用しています。
また、圧縮アルゴリズムだけではなく、各行のフィルタの組み合わせやパレットの順番を変えて、圧縮前のデータのパターンも変化させて試行しているものもあるようです。

ここで重要なのは、当然ですが、どんなに圧縮効率をあげても展開された際のデータはまったく同じだということです。

圧縮最適化の例

OPTPiX でパレット形式にした画像を PNGGauntlet で圧縮最適化した際の変化を例に挙げます。
以下の表は、それぞれのソフトウェアで処理したあとの Deflate ブロックの圧縮率を表しています。

OPTPiX で減色した絵文字スプライト

ブロック番号 展開後のサイズ 圧縮時のサイズ 圧縮率
0 137994 26097 18.91 %
1 53336 8346 15.65 %
Total 191330 34443 18 %
OPTPiX で減色+PNGGauntlet で再圧縮した絵文字スプライト

ブロック番号 展開後のサイズ 圧縮時のサイズ 圧縮率
0 6874 1232 17.92 %
1 5124 1020 19.91 %
2 5136 990 19.28 %
3 6586 1251 18.99 %
4 8570 1664 19.42 %
5 17301 3158 18.25 %
6 4936 1158 23.46 %
7 15310 2795 18.26 %
8 5210 986 18.93 %
9 17072 2743 16.07 %
10 12219 1741 14.25 %
11 19031 2208 11.6 %
12 8084 1399 17.31 %
13 7063 1035 14.65 %
14 3193 598 18.73 %
15 8800 1396 15.86 %
16 16827 2146 12.75 %
17 15412 2109 13.68 %
18 8582 1133 13.2 %
Total 191330 30762 16.08 %

圧縮結果の合計をみると、34,443 Byte と 30,762 Byte になり大きく異なる結果となります。
この例で使用している圧縮率の情報も含め、PNGに関するパレットやフィルタ、圧縮などの情報は拙作の png identify を使うと色々表示できます。

まとめ

ここまで長々といろいろと説明して来ましたが、簡単にまとめます。

  • 減色
    • データそのものを、見た目の違いが分かりにくいように間引いて減らすこと
    • Indexed Colour 形式に変換できるのでファイルサイズが小さくなる
    • 色を減らしているので当然劣化する。劣化具合をよく検証する
  • 圧縮
    • データの表現方法を変えることで短くすること
    • 展開されるデータは変わらないので、圧縮効率最適化できるなら積極的に行うべき
    • 圧縮効率最適化は処理がとても重いのでリアルタイムではやらない方が良い

この記事が何かの参考になれば幸いです。