JavaScript
WebGL
GLSL
Shader
信号処理
WebGLDay 21

DCTでJPEGっぽいエフェクトを作るやつ

本記事は WebGL Advent Calendar 2017 の12月21日向けに投稿した記事です。

前書き

こんにちは、猫です。
普段はWebGLを使ってイケイケなグラフィックを作ることを試みています。できません。
最近では、Datamosh・JPEG風エフェクト・Pixelsortなどのグリッチ表現をシェーダで実装して「これリアルタイムだぜーすごいだろー」と映像勢に自慢することにハマっています。
今回はその中でも一番説明が楽な納得できる結果が得られた、DCTを用いたJPEG風シェーダについて書いてみます。

見た目・デモ

せっかくなので画像やWebCamで使えるようにしました。
悲報: fms-cat.com、httpsじゃないからWebCam使えない

http://fms-cat.com/dct-shader/dist/

ダウンロード (9).png ダウンロード (10).png ダウンロード (8).png

DRZqqvfVoAA5Zpy.jpg

概要

JPEGの圧縮に用いられている、YCbCr変換・DCT・量子化を行うことにより、JPEG風の画像劣化をエフェクトとしてリアルタイムに再現することをしました。
4パス使ってます。

重要となった箇所を順に解説していきたいと思います。

RGB⇔YCbCr変換

JPEGファイルの内部では、画像のデータはYCbCr色空間で表現されている事が多いです。
YCbCr色空間では、RGBのような赤・緑・青の単純な加算合成ではなく、「黒-白」「青緑-赤」「黄緑-青」のような感じで三次元で色を表現します。
人間の目にとっては色合いよりも明るさの情報のほうが重要であるため、この2つを区別して扱うことで効率の良い圧縮を目指しているようです。

デモの左下にある showYCbCr というチェックボックスを入れると、YCbCrの状態の画像がどんな感じになっているかを表示することができます。R・G・BそれぞれがY・Cb・Crに対応しています。
このとき、Cb・Cr成分の値域が[-0.5, 0.5]のため、0.5を上乗せして[0.0, 1.0]の範囲にしています。

ダウンロード (11).png

▲ YCbCr色空間。Yが0.5の場合。横軸がCb[-0.5, 0.5]、縦軸がCr[-0.5, 0.5]

ダウンロード (14).png

fms_cat.png をYCbCr色空間で表示した図。赤色が一番重要らしい

これらを相互変換するための関数は以下の通りとなります。
DCT・量子化の前にRGB→YCbCr、終わった後にYCbCr→RGBを行います。

// RGB to YCbCr
vec3 rgb2yuv( vec3 rgb ) {
  return vec3(
    0.299 * rgb.x + 0.587 * rgb.y + 0.114 * rgb.z,
    -0.148736 * rgb.x - 0.331264 * rgb.y + 0.5 * rgb.z,
    0.5 * rgb.x - 0.418688 * rgb.y - 0.081312 * rgb.z
  );
}
// YCbCr to RGB
vec3 yuv2rgb( vec3 yuv ) {
  return vec3(
    yuv.x + 1.402 * yuv.z,
    yuv.x - 0.344136 * yuv.y - 0.714136 * yuv.z,
    yuv.x + 1.772 * yuv.y
  );
}

関数名がyuvとなっていますが、厳密に言うとYCbCrが正しいです。YUVはアナログ映像信号の世界で使われる言葉・フォーマットらしいです。

DCT

いちばん重要で難しいところです。自分も感覚でしか理解してないので人に説明できるかわかりませんが、どうかお付き合いください。

任意の離散信号はコサイン波の組み合わせで表現することができます。
離散信号をコサイン波の組み合わせの成分表、周波数成分に変換するための処理が離散コサイン変換(DCT)です。
ここで、どのように周波数成分に変換するのかですが、信号自体をコサイン波と順番に掛け合わせていって、その総和がそのまま成分となります(厳密には定数を掛けたりしなきゃいけない)。
さらに、この信号をもとの信号に戻すときも、同様に周波数成分をコサイン波と順番に掛けていけば求まります(逆DCT)。

今回の場合、離散信号は2次元(画像だから2次元、⇔音声データの1次元)の信号が3つ(Y・Cb・Cr)あります。
JPGでは画像全体を8x8のブロックに分割して、その範囲でDCT・量子化(後述)を行っています。
デモの左下にある bypassRDCT というチェックボックスを入れると、逆DCTをバイパスし、周波数成分がどのようなデータになっているかが見れるようになっています(+0.5バイアスを足して表示しています)。

ダウンロード (12).png

▲ JPEGでのDCT圧縮に用いられる64種類の8x8のコサイン波パターン(4倍拡大)。これらを足し引きすると任意の8x8の画像信号が作れる

ダウンロード (16).png

fms_cat.png の周波数成分。ほとんど(0, 0)(定数)成分しか見えない

はじめ、自分は上の画像のようなイメージが強かったため、2重for文を用いて1ピクセルあたり64回もコサイン波と入力信号の掛け算を回していました。
しかし、後ほどこのシェーダが激重状態で動作中の様子をTwitterでシェアしたところ、リアルタイムグリッチ界では神のような存在であるNobyさんから、「DCTはseparableに実装できるよ!」と言われました。
このseparableというのは、ガウスブラーを実装したことがある方ならわかりやすいかもしれませんが、1次元ずつ(この場合、横向きに処理してから縦向き)の処理でも同様の計算結果が得られるということです。
これによって1ピクセルあたり16回の計算で済むようになりました(オーダーにしてO(N^2)からO(N))。

量子化

さて、ここからは楽しいデータ加工タイムです(ホントに楽しい)。
上で行ったDCTがJPEGの圧縮においてどうして必要かなのですが、あらゆる画像において、特定の箇所の周波数成分は偏りがちであるという傾向があります。
わかりやすく言えば、ほとんどの8x8ブロックにおいて、上の画像のようなコサイン波のパターンのうち必要なのはごくごく一部ということです。
JPEGでは、DCTによって求めた周波数成分を量子化することによって、データ量を削減しています。

量子化とは、連続的なデータを飛び飛びの離散データにすることをいいます。量子化幅が大きいほど、量子化されたデータは粗くなり正確度が低くなりますが、データの圧縮には貢献します。
基本的に、周波数が大きければ大きいほど、その周波数成分はあまり重要にならないため(逆に周波数が0=定数部分は成分が大きい)、周波数の増加に応じて量子化幅を大きくします。
ここで、本当なら量子化テーブルを用意しなきゃいけないのですが、今回は簡単のため、周波数の大きさのみを利用して線形に量子化幅を設定しています。

量子化は、DCTと逆DCTの間で行います。今回のコードにはDCTのパスにおいて処理が完了した後に量子化の処理が挟まっています。
もちろん、今回は圧縮を目的とした量子化を行うわけではないので、ここでは好き勝手に周波数成分をいじってOKです(たのしい)。
今回のデモの左下で調整できるパラメータ群のほとんどは、ここで行う処理に関係するものです。

  • 明るさ成分の量子化幅: quantizeY + quantizeYf * 周波数の大きさ
  • 色あい成分の量子化幅: quantizeC + quantizeCf * 周波数の大きさ
  • highFreqMul は周波数が大きければ大きいほど信号を倍増させる(量子化は関係ない)(遊んでる)(たのしい)

前述したように、色合い成分は明るさ成分に対して重要度が低いため、色合い成分のほうが量子化幅は広くなります。

ブロックサイズ

これまでの説明は、ブロックの大きさをJPEGと同じ8x8として説明してきましたが、当然ブロックのサイズは変えられちゃいます。これがやりたかった。
デモの左下から blockSize を他の値に変更すると、ブロックの大きさが変わります。
前述したNobyさんのおかげで、けっこうブロックサイズ大きくしてもリアルタイムで動くようになりました。ありがとうNobyさん🍆

おわり

いま21日の5時18分です。おわり。
あんまりWebGL関係ない記事になっちゃいました。ごめんなさい。

その他

さすがに本物のJPEGで行われているハフマン符号化等を利用した圧縮までをシェーダ内でシミュレーションしているわけではないので、いわゆるJPEG Glitchみたいなものは扱えません。
JPEG Glitchかっこいいですよね。