ウェブ用の PNG を書き出すと、幅と高さは縮めておらず、画素を確認しても変化がないのに、ファイルが大幅に軽くなることがあります。何を省いたのでしょうか。逆に、圧縮レベルを最高へ上げても、あまり小さくならない画像があるのはなぜでしょう。

人工的に作った二つの RGB データで実験しました。一つは規則的に色が変わるパターン、もう一つはノイズ状の対照です。同じグラデーションの画素、同じ圧縮レベルでも、ファイルは 93,425 バイトから 726 バイトになりました。Sharp で書き出すと 571 バイトになりました。今回の入力の結果であり、すべての PNG に約束できる圧縮率ではありません。差の理由と、最適化が必要な情報をひそかに変えていないか確かめる方法を説明します。

「変わっていない」の対象を決める

画像では、幅と高さ、デコード後の色の数値、実際の表示、ファイル全体のバイト列などを比較できます。幅と高さだけでは色が同じとは証明できず、ファイルのハッシュが違うだけで表示が違うとも言えません。最適化の目的を先に明確にすると、何を比較すべきか決まります。

PNG のフィルターと圧縮は画像データを保持できますが、ファイルには画素以外の付加情報も入ります。W3C:PNG 第三版仕様

条件を 256×128、各色チャンネル 8 ビット、RGB の三チャンネル、非インターレースの静止画へ絞りました。透明度チャンネルやカラープロファイルはありません。生成器が数値を書き込み、別のデコード実装で読み出して、元の RGB とバイト単位で比較します。ここでの「一致」はすべての色の数値の一致であり、目で一度見たことを証明としてはいません。

手作業でエンコードした八つのファイルは、それぞれの入力画素を保持する七つの例と、色を意図的に変えた負の対照を含みます。ノイズ状の画像とグラデーションは最初から異なる画像なので、それぞれ自分の元データと比較します。さらに、Sharp での書き出しも一度実行しました。生成器、入力の作り方、ファイルハッシュ、独立した検証結果を提供します。

実行環境は macOS 27.0.1、arm64、Python 3.9.6、zlib 1.2.12です。独立したデコードと書き出しには Node.js 24.19.0、Sharp 0.35.4、libvips 8.18.6、libpng 1.6.58を使いました。このコンピューターで実際に使ったバージョンです。異なるプラットフォームの速度比較や、画像最適化サービスの順位づけは行っていません。

同じ圧縮レベルでも約 128.7 倍違う理由

規則的な入力を示します。生成コードは横座標を赤、縦座標の二倍を緑に使い、青はその和を 256 で割った余りとしています。このため対角線の位置で数値が折り返します。エンコードを分析するためのパターンであり、写真のサンプルではありません。

実験用の 256×128 RGB パターン。座標に応じて規則的に色が変化し、対角線に数値の折り返しがある。

生の RGB 配列は 256×128×3 = 98,304 バイトです。生成器は各行の前にフィルター種別を表す一バイトを加えるため、圧縮へ渡すデータは 98,432 バイトになります。保存された PNG の容量は、エンコード結果とファイル構造にも左右されます。

ファイル行フィルターzlib レベルファイルのバイト数元の RGB と完全一致
gradient-none-0.pngNone098,505はい
gradient-none-9.pngNone993,425はい
gradient-sub-9.pngSub9726はい

zlib のレベルには 0 から 9 が含まれ、0 は圧縮せず、9 は高い圧縮レベルです。この数値は圧縮処理を制御し、入力画素を変えていません。zlib:圧縮レベル

表で最も大きな改善は、レベルがどちらも 9 のファイル間で起きています。違うのは、圧縮前に一行をどう表現するかです。PNG の行フィルターは可逆です。Sub は同じ行の左側にある対応バイトとの差を取り、256 を法とする演算を行います。今回の RGB 配置では、対応する左の値は三バイト前にあります。W3C:PNG のフィルター規則、第 9 節

例えば隣接する二画素の RGB が 100,150,200 と 101,151,201 なら、このスクリプトの Sub 関数は 100,150,200,1,1,1 を出力します。最初の画素は元の値を残し、後ろは変化量を表します。デコード時に復元済みの左側の値を足すことで、二番目の画素を戻せます。

今回の入力は隣り合う色の変化が規則的で、差分列には同じ値が多く現れます。後段の圧縮へ利用しやすい規則性を渡せるため、元の行を直接渡した場合と大きな容量差が生まれました。差分が繰り返されても、画素をまとめて一つにしているわけではありません。逆演算で各位置の元の値を復元できます。

剰余の演算は、負の差をゼロへ切り捨てる処理でもありません。二つ目の実測例は 250,251,255 から 2,3,4 へ進み、後ろの差分は 8,8,5 になりました。同じ規則で足し戻すと値が折り返します。完全なスクリプトの関数は次のとおりです。ここで使った三チャンネル、8 ビットの配置だけを対象にしています。

def sub_row(row):
    return bytes((value - (row[i - 3] if i >= 3 else 0)) % 256
                 for i, value in enumerate(row))

ここでの「フィルター」は、エンコード前の数値の表し方を変える処理です。画像編集ソフトのぼかしフィルターとは意味が異なります。圧縮レベルだけを調整し、前段の表現を見ないと、レベル変更自体より大きな効果を見逃す場合があると分かります。

圧縮データを変えずにファイル容量が変わる

726 バイトのファイルへ、二つの独立した変更を加えました。テキストを追加する変更と、画像データのチャンクを分割する変更です。どちらも圧縮済みの画像データのペイロードは完全に同じです。連結した IDAT ペイロードのハッシュを比較し、再びデコードして検証しました。

ファイル変更IDAT ペイロードのバイト数IDAT チャンク数ファイルのバイト数
gradient-sub-9.png基準6691726
gradient-sub-9-text.pngASCII 文字 4096 個のコメントを追加66914842
gradient-sub-9-split.png各 IDAT のペイロードを最大 16 バイトに分割669421218

PNG のチャンクには長さ、種類、ペイロード、CRC があり、複数の IDAT のペイロードを連結すると一つの圧縮ストリームになります。W3C:チャンク構造と圧縮ストリーム、第 5・10 節

テキストの例では、コメントを独立した tEXt チャンクへ書き込みました。4096 バイトの文字に Comment のキーワードと区切りバイト、チャンクの構造上のオーバーヘッドを加え、合計 4116 バイト増えました。726 から 4842 へ増えても、画素と IDAT は変わっていません。コメントの削除はファイルを小さくしますが、コメントという情報も実際に失われます。

分割の例ではチャンクが 41 個増えました。追加チャンクの長さ、種類、CRC が各 12 バイトなので、41×12 = 492 バイト増えます。小さなチャンクごとに圧縮し直したのではなく、一度得た同じ圧縮ストリームを切って包み直しています。ファイルの包装の仕方による容量増加には、圧縮レベルをさらに上げても効かないと分かります。

ただし、扱ったのは自分で追加したコメントだけです。すべての付加情報を一律に削除する提案へは広げられません。作者の説明、出典、作業工程で必要な項目を残すかは、書き出し用途によって決まります。画像の数値が同じことと、元の情報すべてを保持したことは、異なる確認目標です。

二つの対照で結論を広げすぎない

一つ目の対照は、SHAKE-256 の出力から作ったノイズ状の RGB データです。入力長は同じく 98,304 バイトです。アルゴリズムで生成したテストデータで、カメラセンサーのノイズ特性全体を再現してはいません。

ノイズ状の対照画像。固定した入力の SHAKE-256 出力を RGB のバイトとして使っている。

ファイル行フィルターファイルのバイト数それぞれの元の RGB と完全一致
noise-none-9.pngNone98,530はい
noise-sub-9.pngSub98,530はい

二つのファイルは容量が同じでも、ファイルのバイト列は違います。この入力では、試した二つの表現のどちらもグラデーションのような効果を得られませんでした。圧縮効果がデータ内容に依存し、先ほどの倍率をすべての画像の期待値へ使えないことが分かります。すべてのフィルターと圧縮戦略を試したわけでもないので、最小のエンコードに達したとは言えません。

二つ目の対照では、グラデーションのゼロ始まり座標 (128,64) にある赤のバイトの最下位ビットを反転し、幅と高さを保持しました。gradient-sub-9-changed.png は有効な PNG で、容量は 741 バイトですが、独立した比較は RGB の一バイトの相違を検出しました。

これは確認手順が不一致と判定すべき結果です。幅と高さだけなら「変化なし」と扱ってしまいますが、色の完全な比較では不一致になります。完全な検証器はファイルハッシュとデコード後の形を確かめ、続いて全 RGB バイトを比較して、相違するバイト数を記録します。ハッシュはどのファイルを検証しているかの確認に使い、画素の最終判定には実際のバイト比較を使います。

実際の書き出しへつなげる

実際のツールへつなげるため、Sharp に 93,425 バイトのグラデーションを読み込ませ、最高の圧縮レベルと適応型の行フィルターで書き出しました。検証器の非同期関数で実行した呼び出しは次のとおりです。input と output は実験ディレクトリ内の対応する入力・出力パスです。

await sharp(input)
  .png({ compressionLevel: 9, adaptiveFiltering: true })
  .toFile(output);

出力の gradient-sharp-optimized.png は 571 バイトでした。入力と出力をデコードし、RGB 配列がバイト単位で一致することを確認しました。縮小やパレット量子化は指定していません。量子化は画像を表す色の組を選び直す処理で、元の RGB の数値を変える可能性があります。Sharp の文書は圧縮レベル、適応型フィルター、パレット関連のオプションを区別しています。「品質」という名前のパラメーターすべてが、可逆圧縮だけを調整するとは解釈できません。Sharp:PNG 出力オプション

固定の Sub を使ったファイルより小さく、成熟した実装が別の有効なエンコードを提供できると分かります。571 がこの画像の理論的な下限だとは証明しておらず、書き出し時間も測っていません。画像がすでに適切な方法でエンコードされていれば、最適化をもう一度実行しても明確な効果がないことは自然です。

実際の作業では、まず保持する内容を決め、コピー上で書き出し設定を変えることを提案します。ウェブの挿図なら目標寸法と必要な色や透明効果を、元ファイルの保存なら出典や注釈なども保持したい場合があります。それぞれに明確な確認条件を設け、最小のファイル容量だけで結果を選ばないことです。

ツールの既定の動作も確認する必要があります。Sharp の文書は、keepMetadata を使わない場合、既定で sRGB へ変換し、ICC プロファイルを含むメタデータを削除すると説明しています。Sharp:メタデータの保持

今回の入力にはそれらのプロファイルがなく、上の呼び出しが今回の RGB データに対して有効だったことだけを証明しています。カラープロファイル、透明度、異なるビット深度、アニメーションを含む素材では、その情報をカバーする比較方法が必要です。この三チャンネル検証器から「すべて可逆」という結論は出せません。生成した実験画像だけを扱うことで、対象外の形式を黙って変換してから合格と報告する使い方を避けています。

完全な実験一式 をダウンロードできます。Python の生成器、Node の検証器、すべての画像、生成記録、独立した検証と書き出し結果 を含みます。Python 3.9 以上と、Node から Sharp を読み込める環境で、展開後に次を実行します。

python3 png-size-lab.py png-size-lab
node png-size-verify.cjs png-size-lab

生成器は Python 標準ライブラリだけを使い、検証器には Sharp が必要です。このコンピューターでは環境が提供するインストールを使い、GUMI_SHARP_MODULE でパッケージの絶対パスを指定しました。バージョンは結果に記録しています。再実行すればパターンと検証手順を再現できますが、エンコードするライブラリのバージョンが異なるとファイル容量も異なる可能性があります。実際の結果を確認してください。

信頼できる最適化の報告には、入力、変えたエンコードや付加情報、節約したバイト数、比較によって一致を確認した内容が必要です。小さくなったら行の表現、圧縮、チャンク構造から理由を探し、これ以上小さくならなければ実データと保持条件へ戻ります。必要な内容と実際のバイト削減を根拠に、適切な PNG の書き出し方法を選べます。