Blog
ブログ

2024年12月05日

JPEGエンコーダーをつくろう(1) – SOHOBB AI/BI Advent Calendar 2024

はじめに

 こんにちは。この記事はアドベントカレンダーの5日目の記事になります。
今回は、「JPEGエンコーダーをつくる」をテーマにPythonを用いてJPEGエンコーダーを作りながら画像圧縮符号化の基本的な仕組みについて紹介したいと思います。
私の担当は2024-12-05, 2024-12-15, 2024-12-22なので、ほぼ週刊になるかと思います。創刊号は0円です。
これまでにも画像圧縮の要素技術について紹介したことがありましたが、実際にエンコーダーをつくりながら説明することで、それらがどのように役立っているか、我々が普段目にしている画像はどのような処理が施されたものなのかについて興味を持っていただけたら良いなと思っています。

コンピュータで扱う画像とは

 そもそも、コンピュータにおいて画像はどのように表現されているのでしょうか。
我々が普段コンピュータで扱う画像には、その表現形式の違いからラスタ画像とベクタ画像に分類されます。

 

 ラスタ画像は、画像をピクセルと呼ばれる小さな正方形の集合として扱います。
それぞれのピクセルは固有の色や明るさの情報を持ち、これらのピクセルが平面上に配置されることで画像が形成されます。そのため、ピクセル数を増やすほどより高精細な画像を表現することができます。
しかし、画像サイズを変更しようとすると歪みが発生します。ラスタ画像における拡大・縮小はピクセルを伸ばしたり縮めたりすること相当するためです。
代表的なラスタ画像の符号化方式には、JPEGやPNG, GIFなどがあります。

 

 一方、ベクタ画像では画像を構成する要素を座標と数式で表現します。つまり、点や曲線、多角形の数式の集合として画像を扱います。このため、無限に拡大/縮小処理を行っても歪みは発生しません。
しかし、自然画像に多く含まれる複雑な色の変化や緻密な形状といった情報を、すべて数式で表現することは現実的ではありません。
代表的なベクタ画像の符号化方式には、SVGやPDF(ラスタの場合もある), EPSなどがあります。

JPEGとは

 JPEG(じぇいぺぐ)とは本来、Joint Photographic Experts Groupと呼ばれる主に静止画像符号化標準の制定を行う委員会のアクロニム(一連の頭文字を単語のように発音するタイプの頭字語)です。
JPEGは様々な標準を制定していますが、なかでも有名なものがJPEG 1と呼ばれる標準です。
JPEG 1 は複数のパートから構成されており、パート5で定められているJPEG画像のファイル交換フォーマットであるJPEG File Interchange Format(JFIF)が、一般にJPEG画像やJPEGファイル、または単にJPEGと呼ばれているものになります。
本記事におけるJPEGも静止画像の圧縮符号化、特に非可逆圧縮方式のJFIFを指しており、JFIFにおけるエンコーダーをJPEGエンコーダーと呼んでいます。

 

 (非可逆圧縮符号化方式としての)JPEGでは、原画像から一部の情報を切り捨てて高い圧縮効率を実現しています。
このため、圧縮された画像には歪み・劣化が生じ、一度圧縮された画像から元の画像を完全に復元することは不可能です。
しかしながら、画像や音声といったデータにおいては元データの全体的な印象を損なうことなく表現することができれば問題ない場合が多く、元データを正確に表現することよりもよりコンパクトに圧縮できる方が嬉しいことが多いです。
そこで、JPEGでは人間の視覚特性をうまく利用し、なるべく劣化が目立たないように情報を削減することで高い品質と高い圧縮率を実現しています。

 

 さて、長い前置きは(一旦)終わりにして、さっそくJPEGエンコーダーを作っていきたいと思います。
JPEGエンコーダーでは、入力画像を以下のような流れで処理し、符号化しています。

JPEGエンコーダーの処理の流れを示した図

本記事では、上記の流れに沿って要素技術の説明とPython実装を紹介し、最終回をもって各パーツを組み合わせてJPEGエンコーダーが完成するような構成にしようかと思っています。

JPEGエンコーダーをつくろう(クロマサブサンプリング)

 記念すべき最初のパーツは、クロマサブサンプリングです。
クロマサブサンプリングは、その名の通りクロマ(色信号)をサブサンプリングする(間引く)処理のことですが、なぜ色信号のみ間引き、輝度信号は間引かないのでしょうか。そもそも、デジタル画像における色とはどのように表現されているのでしょうか。

 

 (前置き再開)一般的に画像を表示するディスプレイでは、Red(R), Green(G), Blue(B)の三原色(RGB)を組み合わせて色の表現を行っています。

 

カラーフィルタの配列パターン:ストライプ配列、モザイク配列のイメージ画像
出典:https://www.artiencegroup.com/ja/products/colorfilter/fpd/about_colorfilter.html

RGBはそれぞれの成分が固有の輝度を持っており、Gが最も明るくBが最も暗い色とされています。 すべての成分が最大のとき最も明るい白を、すべての成分が0の時最も暗い黒を表現し、RGBそれぞれの値のバランスで任意の色を表現する仕組みになっています。
我々が普段目にするデジタル画像も、一つのピクセルについてRGBをそれぞれ8bit、合計24bit=3Byteで表現されていることが多いです。

もし、このRGBの情報をそのまま蓄積する場合、ピクセルの情報だけでFHD(1920×1080ピクセル)では約6.2MB、UHD(3840×2160ピクセル)では約24.9MBものサイズになります。

 

 一方で、画像符号化の分野では画像の輝度と色を分けて扱うことが多く、JPEGではYCbCrと呼ばれる色空間に変換し、輝度信号と色差信号を分けて処理しています。
これは、人間の視覚が輝度の信号に対して敏感な一方で色の変化に鈍感である特性を持つことと関係しています。
ヒトをはじめとする脊椎動物の網膜にある視細胞のうち明るさに関係する桿体が、色に関係する錐体よりも10~1000倍近く高い感度であることに関係が…あったりするみたいですが、長くなりそうなので割愛します。
つまり、輝度信号を間引いてしまうと主観的な画質に大きく影響してしまうが、色差信号を間引いても影響が少ないことから、輝度と色信号を分けて扱うYCbCr色空間に変換して、色に関する情報だけを間引いてしまおうというのがクロマサブサンプリングになります。

 

 まず、色空間の変換ですが、JPEGでは下記の変換式を用いてRGB → YCbCrの変換を行っています。



ここで、Yは輝度、Cbは青色成分と輝度の差、Crは赤色成分と輝度の差を表しています。 先ほど、Gが最も明るくBが最も暗いと説明しましたが、JPEGにおけるRGB→YCbCr変換においてもRGBに対する係数の大小関係から、色ごとに明るさが異なることがわかると思います。

 

 つぎに、サブサンプリングですが、色差信号の間引き方については様々な方法があります。
主に、各成分のサンプリング比率と水平/垂直方向のサンプリング比率によって呼び方が異なりますが、ここではその効果をわかりやすくするために、Cb,Cr信号を水平,垂直ともにYに対して半分に間引く4:2:0に限定して取り扱おうと思います。
4:2:0でクロマサブサンプリングすることによって、何も間引かない場合(4:4:4と呼ばれる)と比較してビット数は半分になります。

4:4:4 vs 4:2:0

 

 さて、この処理をPythonで記述する場合、多次元配列の計算の便利のためにnumpyを用いて変換を行うと以下のようなコードになるかと思います。

import numpy as np
import numpy.typing as npt
from PIL import Image

COLOR_COEFFICIENT = np.array([[0.299, 0.587, 0.114], [-0.1687, -0.3313, 0.5], [0.5, -0.4187, -0.0813]]).T
COLOR_INTERCEPT = np.array([[[0, 128, 128]]])


def load_image(path: str):
    image = Image.open(path).convert("RGB")
    image = np.array(image, dtype=np.uint8)
    return image


def convert_color_space(rgb_img: npt.NDArray[np.uint8]):
    """RGB色空間からYCbCr色空間へ変換

    Args:
        rgb_img (npt.NDArray[np.uint8]): RGB画像

    Returns:
        npt.NDArray[np.uint8]: YCbCr画像
    """

    # やることは以下とほぼ同じ
    # r, g, b = np.dsplit(rgb_img, 3)
    # y = np.floor((0.299 * r + 0.587 * g + 0.114 * b) + 0.5).clip(0, 255).astype(np.uint8)
    # cb = np.floor((-0.1687 * r - 0.3313 * g + 0.5 * b) + 128.0 + 0.5).clip(0, 255).astype(np.uint8)
    # cr = np.floor((0.5 * r - 0.4187 * g - 0.0813 * b) + 128.0 + 0.5).clip(0, 255).astype(np.uint8)
    # ycbcr = np.concatenate([y, cb, cr], axis=2)

    ycbcr = np.floor((rgb_img @ COLOR_COEFFICIENT + COLOR_INTERCEPT) + 0.5).clip(0, 255).astype(np.uint8)

    return ycbcr


def chroma_sub_sampling(ycbcr: npt.NDArray[np.uint8]):
    """YCbCr成分を4:2:0でサブサンプリング

    Args:
        ycbcr (npt.NDArray[np.uint8]): YCbCr画像

    Returns:
        tuple[npt.NDarray[np.uint8], npt.NDArray[np.uint8], npt.NDArray[np.uint8]]: サブサンプリング後のY, Cb, Cr成分
    """
    y, cb, cr = np.dsplit(ycbcr, 3)
    new_cb = cb[::2, ::2]
    new_cr = cr[::2, ::2]
    return y, new_cb, new_cr


def main():
    # 原画像をRGBで読み込む
    rgb_image = load_image("path/to/image")

    # RGBからYCbCrに変換する
    ycbcr = convert_color_space(rgb_image)

    # 色差信号を4:2:0で間引く
    y, cb, cr = chroma_sub_sampling(ycbcr)


if __name__ == "__main__":
    main()

🎉おめでとうございます!JPEGエンコーダーにおけるクロマサブサンプリングが完成しました🎉

実験

 実際に上記のコードを利用して、イラストのような画像と写真のような画像に対して、クロマサブサンプリングを施した画像を作成してみます。
参考までに、先ほど定義した関数を用いると以下のように作成できるかと思います。

# 原画像をRGBで読み込む
rgb_image = load_image("path/to/input")

# RGBからYCbCrに変換する
ycbcr = convert_color_space(rgb_image)

# 色差信号を4:2:0で間引く
y, cb, cr = chroma_sub_sampling(ycbcr)

# 半分に間引いたCb, Crを単純リピートして元のサイズにアップサンプリング
upsample = np.dstack([y, cb.repeat(2, axis=0).repeat(2, axis=1), cr.repeat(2, axis=0).repeat(2, axis=1)])

# RGBに戻してBMPで保存
INV_COLOR_COEFFICIENT = np.array([[1.0, 0.0, 1.402], [1.0, -0.34414, -0.71414], [1.0, 1.772, 0.0]]).T
rgb = (upsample - COLOR_INTERCEPT) @ INV_COLOR_COEFFICIENT
Image.fromarray(rgb, mode="RGB").save("path/to/output")

輝度信号のみを水平・垂直に半分に間引いた場合の画像(ここではルーマサブサンプリングと呼ぶが、あまり一般的でない)も作成し、原画像・クロマサブサンプリングと比較した結果がこちらです。


サブサンプリングの結果比較画像

クリスマスツリーの例では、クロマサブサンプリングでは拡大すると色の境界付近でにじみが発生していることがわかると思います。一方で、輝度をサンプリングした場合はツリーの輪郭そのものにジャギーが発生しているように見えます。

写真の場合も同様に、拡大すると

クロマサブサンプリングが原画像に対して50%に相当するビット数を削減しているにもかかわらず、25%削減したルーマサブサンプリングよりも劣化が目立たないことがわかります。

おわりに

 おつかれさまでした。
ここまでの内容で、JPEGエンコーダーにおけるクロマサブサンプリング部分と下記の情報を手に入れました。

  • デジタル画像には主にラスタ画像とベクタ画像があること
  • JPEGはラスタ画像の非可逆圧縮符号化方式であること
  • JPEGエンコーダーは人間の視覚特性を利用して画質をあまり落とさずに色差信号の情報量を削減していること


次回は、クロマサブサンプリングされた各成分からさらに情報量を削減するためのDCTと量子化について紹介したいと思います。
ここまでお付き合いいただきありがとうございました。次回もまたよろしくお願いします。

このページの先頭へ