JPGと離散コサイン変換
誰もがよく知るJPGだが、その裏には学ぶべき圧縮の技術が数多く隠されている。先人たちが賢明なエンジニアリングの工夫を凝らして画像を圧縮してきたのを見ると、この時代に生きられて本当によかったと思えてくる。
JPEG
厳密に言えば、JPEGはファイル形式ではなく、アルゴリズムの一種である。ファイル形式そのものはJFIFによって規定されている。
まずはJPEGにおける色の保存方法から見ていこう。
科学者たちは、人間が色よりも輝度の変化に対してはるかに敏感であることを発見した。そのため、色空間を選択する際には輝度を分離して保存できるYCrCb(YCbCr)が用いられる。
離散コサイン変換
JPEGにおいて画像を魔法のように圧縮できるアルゴリズムがあるとしたら、その最大の立役者は離散コサイン変換(DCT)である。なぜ画像は圧縮できるのか、そしてそれは離散コサイン変換とどう関係しているのだろうか?
画像における高周波と低周波
離散コサイン変換を単純化して理解しやすくするために、まずは1次元配列を例として考えてみよう。画像を信号として捉え、ピクセルのグレースケール値をy軸の高さとすると、次のように表すことができる:
画像を信号として捉えることにどんな利点があるのだろうか?他の信号と同様に処理を行える点にある。数学的には、信号は多項式で表すことができる。
離散コサイン変換の公式は次の通りだ:
処理を行った後にどうなるか、実際に見てみよう:
この公式について、変換後の信号は次のように解釈できる:
cosの各周波数が画像の中でそれぞれどれくらいの割合を占めているか
あるいは、「あらゆる画像は異なる周波数のコサイン波の合成として捉えられる」と考えることもできる。人間にとって重要な信号は主に低周波成分であるため、元信号の高周波成分の係数を下げたり、極端に言えば0に調整したりしても、元の画像にはそれほど影響しないのだ。
2次元離散コサイン変換
2次元の離散コサイン変換の公式はさらに複雑になるが、基本的な原理は同じである。画像はまず8×8の小さなブロックに分割され、DCT係数へと変換される。

Quantization Table(量子化テーブル)
DCTの係数行列をそのまま保存しただけでは、元の画像は一切圧縮されない。実際には、DCT係数行列と量子化テーブルを掛け合わせる(要素ごとに除算する)ことで新しいDCT係数行列が得られ、この係数行列の高周波成分の多くはゼロになる。JPGアルゴリズムは画像を複数の8×8のブロックに分割し、それぞれのブロックに対してDCTを計算する。
圧縮率の選択に応じて、量子化テーブル内の係数も変化する。圧縮を行わない場合、量子化テーブルの要素はすべて 1 になる(画像の各ピクセルがそのまま保持される)。
Zig-zag(ジグザグ走査)
DCT係数行列と量子化テーブルを掛け合わせた後、高周波部分の要素の大半は 0 になる。行列の左上は低周波であり、右下へ行くほど高周波になる。低周波の数値を前方に集めるため、画像ファイルの圧縮時には下図のような順序で走査を行う。

例えば、量子化テーブルと掛け合わせた後の行列が以下のようであったとする:
これを実際のエンコード時には次のように変換する:
[4, 5, 1, 1, 3, 6, 9, 5, 0, 0, 0, 0, 0, 0, 0, 0]
これは、その後の圧縮処理を容易にするためである。
ハフマン符号化
ハフマン符号化は可逆データ圧縮アルゴリズムの一つであり、効率的に文字列を符号化できる。JPGのエンコード時には要素の大部分が 0 となるが、ハフマン符号化は出現頻度の高い値には短いコードを、出現頻度の低い値には長いコードを割り当てる。JPGでは、DCT行列がハフマン符号化を経て保存される。
実際にJPEGをデコードする
JPEGをデコードする手順は以下の通りだ:
- ファイルからハフマン符号テーブルを取り出す
- ファイルからDCT係数行列を取り出す
- 逆DCTを計算した後に量子化テーブルの係数を掛け合わせ、画像内の該当位置のピクセル値を得る
- YCbCrをRGBに変換する
以下はJavaScriptによる簡易的な実装である。こちらの記事(Understanding and Decoding a JPEG Image using Python)を大いに参考にしており、完全なコードは Codepen で確認できる。デコード結果をcanvasに描画している。
注意点として、ここでは putImageData を使用せず、JPGファイルの内容を直接読み取ってcanvas上に1マスずつレンダリングしている(当然ながら、実際の開発でこのような方法は取らない)。
function parseJPG(data) {
const hfTables = {};
const quantizationTables = {};
let quantMapping;
let d = new Uint8Array(data);
let w;
let h;
while (true) {
const marker = new Uint8Array(d.slice(0, 2));
// console.log(marker[0].toString(16), marker[1].toString(16));
if (marker[0] === 0xff && marker[1] === 0xd8) {
console.log("start of image");
d = d.slice(2);
} else if (marker[0] === 0xff && marker[1] === 0xd9) {
console.log("end of image");
return;
} else {
const lenchunk = d.slice(2, 4);
let len = (lenchunk[0] << 8) + lenchunk[1] + 2;
const chunk = d.slice(4, len);
if (marker[0] === 0xff && marker[1] === 0xc4) {
// console.log(d, chunk);
const { table, header } = decodeHuffman(chunk);
hfTables[header] = table;
} else if (marker[0] === 0xff && marker[1] === 0xdb) {
const { header, table } = buildQuantizationTables(chunk);
quantizationTables[header] = table;
} else if (marker[0] === 0xff && marker[1] === 0xc0) {
// start of frame
const { result, width, height } = baseDCT(chunk);
quantMapping = result;
w = width;
h = height;
} else if (marker[0] === 0xff && marker[1] === 0xda) {
// console.log(quantMapping, quantizationTables);
len = startOfScan({
data: d,
hdrlen: len,
width: w,
height: h,
quantizationTables,
quantMapping,
hfTables,
});
}
d = d.slice(len);
}
if (d.length === 0) {
break;
}
}
JPGファイル内には多くのブロックが存在し、各ブロックは 0xff から始まるマーカーで識別される:
0xffc4はハフマンテーブル0xffdaはスキャン開始(Start of Scan)0xffdbは量子化テーブル0xffd8はJPGファイルの開始マーカー(SOI)0xffd9はJPGファイルの終了マーカー(EOI)
バイナリを対応するデータ(ハフマンテーブル、量子化テーブル)に変換した上で計算を行えば、行列が得られる。
テストしてみたところ正常にデコードできなかったため、コードに問題があるようだ。実装を見てもらえればわかるが、計算処理はかなり煩雑である。時間が足りなかったため、ここでは参考用としてひとまず公開しておく。

(上部は正しい色でレンダリングされているようだが、残りの部分が全体的に緑がかってしまっている)
あとがき
実際のアプリケーションでは、GPUやCPUに画像エンコード・デコード用の専用回路が備わっており、通常はわざわざ自前で処理コードを書く必要はない。しかし、JPGの裏にあるアルゴリズムは離散コサイン変換、ハフマン符号化、ジグザグ走査を組み合わせることで、画像の保存容量を見事に削減している。普段当たり前のように使っている技術でも、深く掘り下げてみると学べる知識がたくさんある。僕にとって、そうした探求はとても楽しいものだ。
関連記事
- 測定が目標になるとき:窓税からPull Request数まで かつて僕は小さなツールを自作し、四半期で自分がどれだけPRに貢献したか、レビューコメントをどれだけ残したか、チケットをどれだけ消化したかを集計して、上司にアウトプットを証明しようとしたことがある。上司は淡々と、評価はアウトプットだけで見るものではないと言った。数年後、僕はようやく理解した――測定が目標になるとき、それはもはや良い測定ではなくなるのだ。英国の窓税、ハノイのネズミ駆除の報奨金から、現代のPR数による開発者評価に至るまで、そのメカニズムはまったく同じだ。
- Cloudflare Images を画像ストレージ・変換ソリューションとして使う ウェブページに画像を1枚置くのはフロントエンドにとって最も簡単なことだが、リサイズや各種フォーマットの生成、さらにはトラフィックの負荷に耐えることまで完璧にやろうとすると、実際には一つの包括的なソリューションが必要になる。僕はその後、すべて Cloudflare Images に任せるようになり、オリジナル画像1枚だけを渡すようにしている。
- もう AWS Access Key を使うのはやめよう Access Key は AWS において見落とされがちなセキュリティリスクだ。OIDC と IAM Role を組み合わせることで、GitHub Actions にシークレットを一切保持させることなく、安全に AWS リソースを操作できるようにする。
- データベース主キー:AUTO_INCREMENT、UUID、そしてUUIDv7 バックエンド開発で度々直面する主キーの決定。auto incrementを使うべきか、それともUUIDか?衝突への懸念は?UUIDv7とcreated_at + インデックスの性能差はどれほどか?実際に2,000万件のデータで検証したベンチマークと設計上の意思決定を解説する。