🎆

Rust と Wasm で極限まで PNG ファイルを圧縮した話

2024/05/06に公開

はじめに

以前つくった Minsta というデジタルスタンプラリーの Web アプリの中で、ブラウザ上で Canvas を使用してスタンプ風の画像を生成する機能があるのですが、前々からもう少し画像のファイルサイズを落とせそうだなと思っていたので、今回 Rust と WebAssembly を使用して PNG ファイルを極限まで圧縮するのに挑戦してみました。

Minsta については以下の記事をご覧ください。
https://zenn.dev/wagao/articles/a9219daaf32f18

動機

Minsta では単色で背景透過の以下のようなスタンプの PNG 画像をブラウザ側で Canvas の toBlob メソッドを使用して生成しているのですが、このメソッドで PNG エンコードすると 32 bit の RGB+alpha 形式のファイルしか生成できず、 Minsta で扱うような単色の画像にとっては余計にデータサイズが膨らんでしまっていました。

PNG は使用するカラータイプやビット深度によってファイルサイズを大きく圧縮できるので、スタンプ画像の性質に合わせてできるだけ無駄のない PNG 画像を生成してみようと思ったのが今回の動機です。また、複数のカラータイプやビット深度をサポートしている pngjs などの js ライブラリもあるので通常はこの辺を使ったほうが楽だと思いますが、今回は何となく気になっていた Wasm と Rust を使って実装してみました。

PNG について

PNG にはいくつかの種類があり、同じ画像であっても選択するカラータイプやビット深度などによってファイルサイズは変わるため、なるべくファイルサイズを抑えたい場合は画像の特徴に合わせたフォーマットを選択する必要があります。ここでは PNG の構造のうち今回扱う部分に関して簡単に解説してみます。

その他の PNG の詳しい解説は以下の記事などをご覧ください。

https://qiita.com/spc_ehara/items/c748ec636283df805926

カラータイプ

まず、カラータイプについてですが PNG では Gray Scale(+α チャネル), RGB(+α チャネル), Palette というタイプがあり、これらを大きく分けるとピクセルごとに RGB 値を直接割り当てるトゥルーカラーというタイプと、色情報は PLTE と呼ばれるチャンクに格納しておき、各ピクセルにはパレットのインデックスのみを格納するインデックスカラーというタイプがあります。

トゥルーカラーの場合、例えば数色しか使っていないような画像だとしても、全てのピクセルに対して RGB 値の幅のデータ領域を使用するので、画像によってはファイルサイズが無駄に大きくなってしまう可能性があるのに対し、インデックスカラーの場合、画像内で同じ色はパレット側に 1 色分の RGB 値として持っておき、ピクセルごとのデータとしてはパレットの何番目の色を参照するかというインデックス値のみを持っておけば良いため、インデックスカラー画像の方がファイルサイズを圧縮できます。

しかし、インデックスカラーの最大ビット深度は 8 bit で最大で使用できる色数が 256 色と限られているため、画像内で使用している色数が多い画像だとパレットに収まりきらない色は量子化 (似ている色をまとめたりすることで色数を落とす) する必要があり、元の画像と大きく変わってしまう可能性があります。

ビット深度

ビット深度とは各ピクセルが持つ色情報のサイズを表します。例えば、上で述べたトゥルーカラー形式で各 RGB 値が 0 ~ 255 の幅を持つ場合、ビット深度は 8 bit となり各ピクセルごとに 24 (=8x3) bit の領域を使用することになります。α チャネル付きだとさらに 8 bit 分の領域が必要なのでピクセルごとに 32 (=8x4) bit 使用するという感じになります。インデックスカラーについては、各ピクセルが参照できるパレットのサイズがビット深度になるため、ビット深度が 8 bit の場合、パレットとしては 256 色使用できることになります。これらはそれぞれ PNG24, PNG32, PNG8 などと呼ばれています。

tRNS

パレットには直接 α 値を入れることはできないので、インデックスカラーで透過度を扱うには tRNS チャンクを使用します。ここには PLTE チャンク内の順番と対応するように α 値が格納されており、 PLTE チャンクに格納された RGB 値と合わせて透過度付きで各ピクセルの色を表現できるようになっています。

今回やったこと

今回色々なパターン試行錯誤した結果、最終的には以下のような形に行き着きました。基本的には汎用性は無視してスタンプ画像に特化した実装になっています。

  • パレットは 1 色のみ (実際には透過度の都合上 4 色分のデータ領域を使用)
  • 透過度 (tRNS) は 4 つの値に量子化
  • ビット深度は 2 bit

まず、スタンプ画像で使用する色は常に 1 色なのでパレットに入れる色も 1 色のみとしました。ただ、次に述べる透過度を 4 種類使用する都合上、データとしては 4 色分の同じ色の値を入れる必要がありました。

次に透過度に関してですが、最初に α 値には 0 (背景部分) か 255 (色がある部分) の 2 値だけを使うように実装してみたのですが、エンコードしてみると以下のようにカーブ部分でガタつきが目立つようになってしまいました。

これは一見スタンプ画像を見る限り α 値は 0 か 255 の 2 値しか使っていないように見えますが、画像を拡大してみるとカーブ部分がなだらかになるように複数の α 値が使われているため、これらを完全に 2 値化することでガタつきが出てしまったようです。実際にデータを読み込んで確認すると確かにピクセルごとに α 値はかなりばらつきがあることがわかりました。

これだと見た目的に少し気になったので元の α 値を 0, 85, 170, 255 という 4 つの値に量子化して tRNS に割り当てるようにしました。このようにすることで以下のようにカーブ部分を滑らかにすることができました。

最後にビット深度は透過度を 4 種類使用するため自動的に 2 bit 必要ということになり、スタンプ画像には tRNS 付き 2 ビットインデックスカラーというフォーマットが最適という結論になりました。

実装

以下が今回実装する PNG フォーマットのイメージ図になります。このフォーマットになるように実装をしていきます。

パレットの生成

まずパレットの生成についてですが、今回扱う色は 1 色なので js 側からスタンプの色を rgb の配列 (target_rgb) として受け取り、それらを 4 つ結合してパレットとしました。

let mut palette: Vec<u8> = Vec::new();
for _ in 0..4 {
    palette.extend_from_slice(&target_rgb);
}

インデックスの生成

次にピクセルごとにパレットに対するインデックスを生成する部分ですが、全てのピクセルに対して α 値だけを取り出して適当な閾値で TRNS に定義した配列のインデックスにマップしていくことでインデックスを生成しています (今回パレットは全て同じ色なので tRNS のインデックスと対応していれば良い)。また、今回使用するビット深度は 2 bit なので 2 bit ずつ区切ってベクトルに詰めていく必要がありますが、Rust では標準で bit 単位での操作はできないので bitvec というクレートを使用して実装しました。

const TRNS: [u8; 4] = [0, 85, 170, 255];

let indices: BitVec<u8, Msb0> = buf
    .chunks_exact(4)
    .flat_map(|chunk| match chunk[3] {
        a if a > 213 => bitvec![1, 1], // TRNS[3]
        a if a > 128 => bitvec![1, 0], // TRNS[2]
        a if a > 42 => bitvec![0, 1],  // TRNS[1]
        _ => bitvec![0, 0],            // TRNS[0]
    })
    .collect();

PNG エンコード

PNG にエンコードする部分は、png というクレートを使用しました。以下のようにカラータイプ、ビット深度、パレットなど必要な情報をセットして write_image_data() でデータを PNG にエンコードしています。

let mut encoder = png::Encoder::new(&mut encoded_data, width, height);
encoder.set_color(png::ColorType::Indexed);
encoder.set_depth(png::BitDepth::Two);
encoder.set_compression(png::Compression::Best);
encoder.set_filter(png::FilterType::NoFilter);
encoder.set_palette(palette);
encoder.set_trns(Vec::from(TRNS));

let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&indices).unwrap();

アプリからの呼び出し

js から Wasm の関数を呼ぶために wasm-bindgen クレートを使用しており、js から呼び出したい関数に #[wasm_bindgen] という属性をつけています。これを wasm-pack などを使用してビルドすることで encode_2bit_indexed_trns_png() を js から呼び出すことができるようになります。今回はビルドしたパッケージを npm に publish してアプリ側からインストールするようにしました。

https://www.npmjs.com/package/@wagao29/canvas2png

#[wasm_bindgen]
pub fn encode_2bit_indexed_trns_png(
    buf: Vec<u8>,
    width: u32,
    height: u32,
    target_rgb: Vec<u8>,
) -> Vec<u8> {
...

アプリ側は以下のような形で呼び出しています。一応ブラウザ側で Wasm をサポートしていない可能性を考慮して最初に typeof WebAssembly === 'object' でチェックして、非対応の場合は従来の toBlob() を使用した方法にフォールバックするようにしています。

import * as wasm from '@wagao29/canvas2png';

if (typeof WebAssembly === 'object') {
  const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const pixelData = Uint8Array.from(imageData.data);
  const colorCode = Uint8Array.from(splitColorCode(stampColor));
  const pngData = wasm.encode_2bit_indexed_trns_png(
    pixelData,
    canvas.width,
    canvas.height,
    colorCode
  );
...

ファイルサイズの変化

最後にファイルサイズがどのくらい小さくなったか確認してみると、同じスタンプ画像をエンコードした際に toBlob 版に対して Wasm 版が 1/4 近くファイルサイズを圧縮できたことがわかりました。もし Minsta が大規模サービスだったらかなりのコスト削減が見込めそうです。(現状元のファイルサイズでも Firebase の無料枠で余裕で収まってますが...)

❯ pngcheck -cvt toBlob.png
File: toBlob.png (21409 bytes)
  chunk IHDR at offset 0x0000c, length 13
    264 x 264 image, 32-bit RGB+alpha, non-interlaced
  chunk sRGB at offset 0x00025, length 1
    rendering intent = perceptual
  chunk IDAT at offset 0x00032, length 8192
    zlib: deflated, 32K window, fast compression
  chunk IDAT at offset 0x0203e, length 8192
  chunk IDAT at offset 0x0404a, length 4931
  chunk IEND at offset 0x05399, length 0
No errors detected in toBlob.png (6 chunks, 92.3% compression).

❯ pngcheck -cvt wasm.png
File: wasm.png (5571 bytes)
  chunk IHDR at offset 0x0000c, length 13
    264 x 264 image, 2-bit palette, non-interlaced
  chunk PLTE at offset 0x00025, length 12: 4 palette entries
  chunk tRNS at offset 0x0003d, length 4: 4 transparency entries
  chunk IDAT at offset 0x0004d, length 5474
    zlib: deflated, 32K window, maximum compression
  chunk IEND at offset 0x015bb, length 0
No errors detected in wasm.png (5 chunks, 68.0% compression).

おわりに

今回 Rust と Wasm を使って PNG 画像の圧縮をやってみましたが、最終的に予想通りかなり大きくファイルサイズを削減できて良かったです。Wasm も今回初めて使いましたが、wasm-pack や wasm-bindgen のおかげでかなり簡単に導入できることがわかりました。また、今回実装するにあたって PNG のフォーマットもある程度分かったので、今度はエンコード部分も自作してみたいなと思ったりもしてます。

最後に、良かったら今回の Wasm による PNG エンコードの実装が入った Minsta も遊んでみてください。

https://minsta.app/

ここまで読んでいただきありがとうございました!

Discussion