はじめに
最近JPEGエンコードの方法を学んでおり、ネットで多くの記事を探しましたが、すべての詳細を明確に説明している記事はほとんどなく、プログラミングの際に多くの落とし穴にハマりました。そこで、できるだけPythonコードを交えて詳細を説明する記事を書こうと思います。具体的なプログラムは私のGitHub上のオープンソースプロジェクトを参照してください。
もちろん、この紹介やコードはあまり完璧ではなく、間違いもあるかもしれません。あくまで入門ガイドとしてご容赦ください。
JPEGファイルにおける各種マーカー
多くの記事でJPEGファイルのマーカーについて紹介されていますが、私も実際の画像に注釈を付けたドキュメント(ダウンロード)をアップロードしましたので参考にしてください。
すべてのマーカーは0xff(16進数の255)で始まり、その後にこのブロックのデータのバイト数とブロック情報を記述するデータが続きます。具体的には下図の通りです:

ここまでで、画像データ部分だけがまだ書き込まれていません。しかし、画像データ部分がどのようにエンコードされるのか、また前述の量子化やハフマン符号化が具体的にどのように実装されるのかについては、次のパートの説明をご覧ください。
JPEGエンコードの流れ
JPEGエンコードの過程では画像を8×8のブロックに分割する必要があるため、画像の高さと幅がともに8の倍数であることが求められます。そのため、画像の補間やサンプリングの方法を用いて、画像をわずかに変更し、高さと幅が8の倍数になるようにします。何千何万ものピクセルからなる画像にとって、この操作は画像全体のアスペクト比に大きな変化をもたらしません。
色空間の変換
JPEG画像ではYCbCr色空間が統一して使用されます。これは人間の目が輝度に対して敏感で、色差に対しては鈍感であるため、CbとCr成分の圧縮を選択的に強めることで、画像の見た目を保ちつつ、より大きくファイルサイズを削減できるからです。YCbCr空間に変換した後、CbとCrの色成分をサンプリングして点数を減らすことで、さらに圧縮を進めることができます。一般的なサンプリング形式には4:4:4、4:2:2、4:2:0があります。これらはSOF0マーカー内の水平サンプリング係数と垂直サンプリング係数に対応します。簡単のため、本記事ではすべてのサンプリング係数を1、つまりサンプリングを行わず、1つのY成分に1つのCb Cr成分が対応する(4:4:4)とします。4:2:2は2つのY成分に1つのCb Cr成分が対応し、4:2:0は4つのY成分に1つのCb Cr成分が対応します。下図のように、各セルが1つのY成分に対応し、青いマス目がCb Cr成分のサンプリング点です。

色空間変換の式は次のとおりです:
上記の演算はすべて四捨五入して整数にします。24ビットのRGB BMP画像ではR、G、B成分の範囲は0~255であり、簡単な数学的関係から、Y、Cb、Cr成分の範囲も0~255であることがわかります。JPEG画像では、通常、各成分から128を引いて、範囲が正負にわたるようにします。
PythonではOpenCVライブラリの関数を使って色空間変換を行うことができます:
8×8ブロック分割
JPEGエンコードでは、各8×8ブロックに対して処理を行い、上から下、左から右の順にデータ処理を進め、最後に各ブロックのデータを結合します。各ブロックのY、Cb、Crの3つの色成分については、Y、Cb、Crの順に同じ操作を行います(使用する量子化テーブルとハフマンテーブルは異なります)。
DCT変換
DCT(離散コサイン変換)は、空間領域のデータを周波数領域に変換して演算を行うもので、これにより周波数領域で高周波成分のデータを選択的に削減しても、画像の見た目に大きな影響を与えません。また、離散フーリエ変換と比べて、離散コサイン変換はすべて実数領域で演算されるため、コンピュータでの計算に適しています。離散コサイン変換の式は次のとおりです:
ここで です。JPEGでは です。
もちろんOpenCVライブラリの関数を使うこともできます:
量子化
DCT変換後、直流成分は8×8ブロックの最初の要素となり、低周波成分は左上に集中し、高周波成分は右下に集中します。高周波成分を選択的に除去するために、量子化操作を行います。これは実際には8×8ブロックの各要素を一定の値で割ることです。量子化テーブルでは左上の要素が小さく、右下が大きくなっています。量子化テーブルの例を以下に示します(Y成分とCb Cr成分で異なる量子化テーブルを使用します):
量子化処理のコード:
量子化後、8×8ブロックの右下部分に多くの0が現れます。これらの0を集中させ、ランレングス符号化でより少ないデータ量にするために、次にジグザグスキャンを行います。
ジグザグスキャン
ジグザグスキャンとは、実際には8×8のブロックを以下の順序で64項目のリストに変換することです。

最終的に、次のような長さ64のリストが得られます:(41, -8, -6, -5, 13, 11, -1, 1, 2, -2, -3, -5, 1, 1, -5, 1, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1, 1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0)。以降の操作ではこのリストを例に説明します。
注意すべき点として、量子化テーブルを保存する際にも、対応するジグザグスキャンを行う必要があります。この形式で保存することで、画像ビューアが正しく画像をデコードできます(私は最初この細かい点で多くのデバッグ時間を費やしました)。本文の最初のマーカー書き込みコードを参照してください。
差分符号化(直流成分)
直流成分の値はしばしば大きく、また隣接する8×8ブロックの直流成分は非常に近い値になることが多いため、差分符号化を用いることでよりスペースを節約できます。差分符号化とは、現在のブロックと前のブロックの直流成分の差分を保存するもので、最初のブロックはそのまま保存します。注意点として、Y、Cb、Crの3つの成分はそれぞれ対応して差分符号化が行われ、各成分ごとに引き算が行われます。直流成分nowblockdcの符号化と保存方法については後述します。
ゼロのランレングス符号化(交流成分)
ジグザグスキャン後、多くの0が集中し、交流成分のリストは次のようになります:(-8, -6, -5, 13, 11, -1, 1, 2, -2, -3, -5, 1, 1, -5, 1, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1, 1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0)。
ゼロのランレングス符号化では、毎回2つの数を保存します。2つ目の数は非ゼロの数で、1つ目の数はその非ゼロの数の前にある0の個数です。最後に2つの0を終端マーカーとして付けます(特に注意:入力データが0で終わらない場合、2つの0の終端マーカーは不要です。このバグを見つけるのに長い時間がかかりました。下のコードの23行目を参照)。上記のリストをランレングス符号化すると次のようになります:(0, -8), (0, -6), (0, -5), (0, 13), (0, 11), (0, -1), (0, 1), (0, 2), (0, -2), (0, -3), (0, -5), (0, 1), (0, 1), (0, -5), (0, 1), (3, -1), (6, 1), (0, 1), (0, -1),(27, 1), (0, 0)。このデータの長さは42で、元の63に比べていくらか減少しています。もちろん、ここでは特殊なデータを選んでいますが、実際のデータではもっと多くの0が含まれ、すべて0になることもあり、符号化後のサイズはさらに小さくなります。
上記のデータで(27, 1)が赤くなっているのに気づいたかもしれません。これは、第8部の符号化において、最初の数は4ビットで保存されるため、範囲が0~15であり、ここでは明らかに超えているからです。そのため、(15, 0), (11, 1)に分割する必要があります。(15, 0)は16個の0を表し、(11, 1)は11個の0の後に1があることを表します。
JPEG特殊バイナリ符号化
以上の準備を経て、このパートでは、符号化された直流成分と交流成分がどのようにデータストリームとしてファイルに書き込まれるかを実際に紹介します。
JPEG符号化では、次のようなバイナリ符号化形式があります:
保存する数値について、上記の形式に従って、保存に必要なビット長と実際の保存バイナリ値を得る必要があります。その規則を観察すると、正の数の保存値はその実際のバイナリ表現であり、ビット長も実際のビット長であることがわかります。対応する負の数も同じビット長で、バイナリ値はビット反転したものになります。0は保存不要です。
直流成分について、差分符号化後の値が-41だとすると、上記の操作によりビット長は6、保存バイナリストリームは010110となります。数値6については、正規ハフマン符号化を用いてそのバイナリストリームを保存する必要があります。これについては第9部で説明しますが、ここでは6の保存バイナリストリームが100であると仮定します。すると、この8×8ブロックのある色成分の直流成分は100010110として保存されます。
直流成分のバイナリストリームをファイルに書き込んだ後、次にこの8×8ブロックの同じ色成分の交流成分を符号化します。ランレングス符号化後の値は次のとおりです:(0, -8), (0, -6), (0, -5), (0, 13), (0, 11), (0, -1), (0, 1), (0, 2), (0, -2), (0, -3), (0, -5), (0, 1), (0, 1), (0, -5), (0, 1), (3, -1), (6, 1), (0, 1), (0, -1),(15, 0), (11, 1) , (0, 0)。
まず(0, -8)を保存します。2つ目の数についても同様の操作を行い、4ビットと0111が得られますが、直流成分と異なるのは、0x04に対して正規ハフマン符号化を行う点です。ここで上位4ビットは(0, -8)の1つ目の数、下位4ビットは2つ目の数の保存ビット長です。0x04の正規ハフマン符号化後の保存値が1011だとすると、(0, -8)は10110111として保存されます。次に(0, -6)などについても同様の操作を行い、得られたデータストリームを順次ファイルに書き込みます。
もう一つの例として(6, 1)では、1は1ビットで1として保存されるため、0x61に対して正規ハフマン符号化を行います。仮に1111011だとすると、(6, 1)は11110111として保存されます。(15, 0)の場合は0xf0の正規ハフマン符号化値のみが保存されます。
上記の手順で1つの色成分(例えばY)のデータを書き終えたら、次にこの8×8ブロックのCb色成分のデータを書き、さらにCr成分のデータを書きます。同様の方法で、左から右、上から下へと各8×8ブロックのデータを書き込んだ後、EOIマーカー(0xffd9)を書き込んで画像の終了とします。
注意:データ書き込み中に0xffが書き込まれていないか検出する必要があります。マーカーとの衝突を防ぐため、0xffの後には0x00を補います。
正規ハフマン符号化
本記事で紹介する正規ハフマン符号化には4つの符号化テーブルがあり、それぞれ輝度直流成分、色差直流成分、輝度交流成分、色差交流成分に使用されます。
上記コードのstdhuffmanDC0などは、実際にマーカー内に保存される値です。詳細はマーカーの紹介のコードを参照してください。この数字列のうち、最初の16個の数字(0, 0, 7, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0)は、符号化後の長さが1~16ビットのものがそれぞれ何個あるかを表し、その後に続く12個の数字はちょうど最初の16個の数字の合計になっています。stdhuffmanDC0が実際に記述しているのは下図の通りです:

今のところ、各元データの符号化後のデータ長だけがわかっており、実際の値はわかりません。
正規ハフマン符号化には独自の規則があります:
- 最小符号長の最初の数の符号は0です;
- 同じ符号長の符号は連続しています;
- 次の符号長(jとします)の最初の数の符号aは、前の符号長(iとします)の最後の数の符号bに依存し、
a=(b+1)<<(j-i)となります。
規則1により、4の符号は000であることがわかります。規則2により、5の符号は001、3の符号は010、2の符号は011...、0の符号は110となります。規則3により、7の符号は1110、8の符号は11110...となります。
最終的に得られるハフマン辞書はかなり長いので、私のGitHubプロジェクトで確認してください。その規則を見つければ、write_num関数内で辞書のインデックスをそのように求めている理由が理解できるでしょう。