PythonでOpenCVを使った色空間の変換
今回は「PythonでOpenCVを使った色空間の変換」を学んでいきます。
それぞれのバージョンはPython 3.10.4、OpenCV 4.5.5になります。また、今回の記事の内容はOpenCVの公式ドキュメントを参考にしています。
OpenCVで色空間の変換処理
OpenCVではcvtColorという色空間の変換処理を行う関数が実装されています。引数に変換前後の色空間を指定することで、様々な色空間への変換を行うことができます。
cv.cvtColor(src, code[, dst[, dstCn]])
| Parameters | 説明 | 
|---|---|
| src | 符号なしの8ビット or 16ビットまたは32ビット浮動小数点数(CV_32F)の画像 | 
| code | 色空間変換コード | 
| dst | srcと同じサイズと深さの画像 | 
| dstCn | 出力画像のチャンネル数(0の場合は元画像より自動的に算出) | 
各チャンネルの扱い
OpenCVでは各チャンネルをBGRの順で保持し、値は以下の3つの型のどれかで表されます。
| 型 | 範囲 | 
|---|---|
| CV_8U | 0 - 255 | 
| CV_16U | 0 - 65535 | 
| CV_32F | 0 - 1 | 
ただし、最近は色空間の変換時に16bitの画像がサポートされていない場合が多いため、実質CV_8UまたはCV_32Fのみとなります。
変換時の注意点
非線形色空間への変換
公式のドキュメントでは、RGBからLuv等の非線形の色空間に変換する際は、正しい結果を得るためにcvtColorで変換する前に適切な値の範囲に正規化を自身で行う必要があると記載があります。
src_img = (1.0 / 255) * src_img.astype(np.float32)
dst_img = cv.cvtColor(src_img, cv2.COLOR_BGR2Luv)
ただし、非線形色空間への変換時に8bitまたは16bitの画像が入力された場合は変換前に自動で適切な正規化が内部で行われるため、以下で紹介するように一部でも情報が失われると困る場合を除き、特に事前の正規化等は必要なさそうです。
8bit画像の変換
通常、8bit画像での変換では一部の情報が失われます。もし、全範囲の色を必要とする場合や読み込み時に画像を変換して元に戻すような処理をする場合は、32bit画像を使用するほうが良いでしょう。
アルファチャンネルを追加する変換
変換によってアルファチャンネルが追加される場合、その値は対応するチャンネル範囲の最大値に設定されます。
CV_8Uの場合は255、CV_16Uの場合は65535、CV_32Fの場合は1となります。
色空間変換コードの種類について
OpenCVでは様々な色空間への変換ができるように200以上の変換コードが実装されています。ここではすべて紹介できないため、実装で使用する一部の変換コードとそれに対応する逆変換コードのみを以下に記載します。
| 説明 | 色空間変換コード | 色空間逆変換コード | 
|---|---|---|
| BGRをグレースケールに変換 | cv.COLOR_BGR2GRAY | cv.COLOR_GRAY2BGR | 
| BGRにアルファチャンネルを追加 | cv.COLOR_BGR2BGRA | cv.COLOR_BGRA2BGR | 
| BGRをHSVに変換 | cv.COLOR_BGR2HSV | cv.COLOR_HSV2BGR | 
| BGRをLabに変換 | cv.COLOR_BGR2Lab | cv.COLOR_Lab2BGR | 
| BGRをLuvに変換 | cv.COLOR_BGR2Luv | cv.COLOR_Luv2BGR | 
| BGRをHLSに変換 | cv.COLOR_BGR2HLS | cv.COLOR_HLS2BGR | 
| BGRをYUVに変換 | cv.COLOR_BGR2YUV | cv.COLOR_YUV2BGR | 
| BGRをXYZに変換 | cv.COLOR_BGR2XYZ | cv.COLOR_XYZ2BGR | 
| BGRをYCrCbに変換 | cv.COLOR_BGR2YCrCb | cv.COLOR_YCrCb2BGR | 
OpenCVのすべての色空間変換コードはこちらから確認できます。
また、変換に使用している数式に関してはこちらから確認できます。
実装
実際の実装はこのようになります。
import sys
import cv2
def main() -> None:
    input_path = sys.argv[1]
    src_img = cv2.imread(input_path)
    if src_img is None:
        raise Exception()
    cvt_img = cv2.cvtColor(src_img, cv2.COLOR_BGR2GRAY)
    cv2.imwrite("gray.png", cvt_img)
if __name__ == "__main__":
    main()
| 1.1 入力画像 | 1.2 出力画像 | 
|---|---|
|  sciencepark.png |  gray.png | 
BGR(「1.1 入力画像」)からグレースケールに変換するコード(cv2.COLOR_BGR2GRAY)を使用して、グレースケール画像(「1.2 出力画像」)を取得することができました。
さらに、各変換コードを使用した結果を各チャンネルごとに以下に記載します。
cv2.COLOR_BGR2BGRA
| 2.1 出力画像 | 2.2 出力画像 | 2.3 出力画像 | 2.4 出力画像 | 
|---|---|---|---|
|  B |  G |  R |  A | 
cv2.COLOR_BGR2HSV
| 3.1 出力画像 | 3.2 出力画像 | 3.3 出力画像 | 
|---|---|---|
|  H |  S |  V | 
cv2.COLOR_BGR2Lab
| 4.1 出力画像 | 4.2 出力画像 | 4.3 出力画像 | 
|---|---|---|
|  L |  a |  b | 
cv2.COLOR_BGR2Luv
| 5.1 出力画像 | 5.2 出力画像 | 5.3 出力画像 | 
|---|---|---|
|  L |  u |  v | 
cv2.COLOR_BGR2HLS
| 6.1 出力画像 | 6.2 出力画像 | 6.3 出力画像 | 
|---|---|---|
|  H |  L |  S | 
cv2.COLOR_BGR2YUV
| 7.1 出力画像 | 7.2 出力画像 | 7.3 出力画像 | 
|---|---|---|
|  Y |  U |  V | 
cv2.COLOR_BGR2XYZ
| 8.1 出力画像 | 8.2 出力画像 | 8.3 出力画像 | 
|---|---|---|
|  X |  Y |  Z | 
cv2.COLOR_BGR2YCrCb
| 9.1 出力画像 | 9.2 出力画像 | 9.3 出力画像 | 
|---|---|---|
|  Y |  Cr |  Cb | 
色空間変換は可逆?不可逆?検証してみた
色空間の変換後に逆変換を行い、変換前と逆変換後の画像を比較することで検証しました。
画像同士の比較には、numpyのarray_equal()を使用し、差異があった場合はnumpyのisclose()を使用して、その割合を算出しています。
8bit画像と32bit画像それぞれで検証に使用したスクリプトと結果をまとめた表を以下に記載します。
8bit画像の場合
import sys
import numpy as np
import cv2
import cvex
def main() -> None:
    input_path = sys.argv[1]
    src_img = cvex.imread(input_path)
    if src_img is None:
        raise Exception()
    cvt_code_dict = {
        cv2.COLOR_BGR2GRAY: "GRAY",
        cv2.COLOR_BGR2BGRA: "BGRA",
        cv2.COLOR_BGR2HSV: "HSV",
        cv2.COLOR_BGR2Lab: "Lab",
        cv2.COLOR_BGR2Luv: "Luv",
        cv2.COLOR_BGR2HLS: "HLS",
        cv2.COLOR_BGR2YUV: "YUV",
        cv2.COLOR_BGR2XYZ: "XYZ",
        cv2.COLOR_BGR2YCrCb: "YCrCb",
    }
    decvt_code_dict = {
        "GRAY": cv2.COLOR_GRAY2BGR,
        "BGRA": cv2.COLOR_BGRA2BGR,
        "HSV": cv2.COLOR_HSV2BGR,
        "Lab": cv2.COLOR_Lab2BGR,
        "Luv": cv2.COLOR_Luv2BGR,
        "HLS": cv2.COLOR_HLS2BGR,
        "YUV": cv2.COLOR_YUV2BGR,
        "XYZ": cv2.COLOR_XYZ2BGR,
        "YCrCb": cv2.COLOR_YCrCb2BGR,
    }
    for code, v in cvt_code_dict.items():
        cvt_img = cv2.cvtColor(src_img, code)
        decvt_img = cv2.cvtColor(cvt_img, decvt_code_dict[v])
        flag = np.array_equal(src_img, decvt_img)
        print("{}:{}".format(v, flag))
        if not flag:
            flag_mask = np.isclose(src_img, decvt_img)
            src_diff_array = np.ravel(src_img[~flag_mask].astype(np.float32))
            cvt_diff_array = np.ravel(decvt_img[~flag_mask].astype(np.float32))
            diff_list = abs(src_diff_array - cvt_diff_array)
            diff_len = len(diff_list)
            diff_sum = sum(diff_list)
            h, w = src_img.shape[:2]
            pixels = h * w
            print("元画像と異なるピクセルの割合:{}%".format((diff_len / (pixels * 3)) * 100))
            print("異なる値の平均幅:{}".format((diff_sum / diff_len)))
if __name__ == "__main__":
    main()
| 色空間変換コード | 色空間逆変換コード | 差異の有無 | 異なるピクセルの割合 | 異なる値の平均幅(0~255) | 
|---|---|---|---|---|
| cv.COLOR_BGR2GRAY | cv.COLOR_GRAY2BGR | あり | 93.54188368% | 17.98067933 | 
| cv.COLOR_BGR2BGRA | cv.COLOR_BGRA2BGR | なし | 0% | 0 | 
| cv.COLOR_BGR2HSV | cv.COLOR_HSV2BGR | あり | 31.13129340% | 1.06006148 | 
| cv.COLOR_BGR2Lab | cv.COLOR_Lab2BGR | あり | 39.09743924% | 1.04829014 | 
| cv.COLOR_BGR2Luv | cv.COLOR_Luv2BGR | あり | 52.36208767% | 1.12438843 | 
| cv.COLOR_BGR2HLS | cv.COLOR_HLS2BGR | あり | 31.66710069% | 1.02539713 | 
| cv.COLOR_BGR2YUV | cv.COLOR_YUV2BGR | あり | 27.06043837% | 1.00000000 | 
| cv.COLOR_BGR2XYZ | cv.COLOR_XYZ2BGR | あり | 42.04947917% | 1.50939287 | 
| cv.COLOR_BGR2YCrCb | cv.COLOR_YCrCb2BGR | あり | 2.63856337% | 1.00000000 | 
32bit画像の場合
import sys
import numpy as np
import cv2
import cvex
def main() -> None:
    input_path = sys.argv[1]
    src_img = cvex.imread(input_path)
    if src_img is None:
        raise Exception()
    cvt_code_dict = {
        cv2.COLOR_BGR2GRAY: "GRAY",
        cv2.COLOR_BGR2BGRA: "BGRA",
        cv2.COLOR_BGR2HSV: "HSV",
        cv2.COLOR_BGR2Lab: "Lab",
        cv2.COLOR_BGR2Luv: "Luv",
        cv2.COLOR_BGR2HLS: "HLS",
        cv2.COLOR_BGR2YUV: "YUV",
        cv2.COLOR_BGR2XYZ: "XYZ",
        cv2.COLOR_BGR2YCrCb: "YCrCb",
    }
    decvt_code_dict = {
        "GRAY": cv2.COLOR_GRAY2BGR,
        "BGRA": cv2.COLOR_BGRA2BGR,
        "HSV": cv2.COLOR_HSV2BGR,
        "Lab": cv2.COLOR_Lab2BGR,
        "Luv": cv2.COLOR_Luv2BGR,
        "HLS": cv2.COLOR_HLS2BGR,
        "YUV": cv2.COLOR_YUV2BGR,
        "XYZ": cv2.COLOR_XYZ2BGR,
        "YCrCb": cv2.COLOR_YCrCb2BGR,
    }
    # src_cvt_img = src_img.copy()
    for code, v in cvt_code_dict.items():
        src_cvt_img = src_img.astype(np.float32) * (1.0 / 255)
        cvt_img = cv2.cvtColor(src_cvt_img, code)
        decvt_img = cv2.cvtColor(cvt_img, decvt_code_dict[v])
        flag = np.array_equal(src_cvt_img, decvt_img)
        print("{}:{}".format(v, flag))
        if not flag:
            flag_mask = np.isclose(src_cvt_img, decvt_img)
            src_diff_array = np.ravel(src_cvt_img[~flag_mask].astype(np.float32))
            cvt_diff_array = np.ravel(decvt_img[~flag_mask].astype(np.float32))
            diff_list = abs(src_diff_array - cvt_diff_array)
            diff_len = len(diff_list)
            diff_sum = sum(diff_list)
            h, w = src_cvt_img.shape[:2]
            pixels = h * w
            print("元画像と異なるピクセルの割合:{:.8f}%".format((diff_len / (pixels * 3)) * 100))
            print("異なる値の平均幅:{:.8f}".format((diff_sum / diff_len)))
            print("8bitだった場合の平均幅:{:.8f}".format((diff_sum / diff_len) * 255))
if __name__ == "__main__":
    main()
| 色空間変換コード | 色空間逆変換コード | 差異の有無 | 異なるピクセルの割合 | 異なる値の平均幅(0~1) | 異なる値の平均幅(0~255) | 
|---|---|---|---|---|---|
| cv.COLOR_BGR2GRAY | cv.COLOR_GRAY2BGR | あり | 99.23470052% | 0.06650403 | 16.95852754 | 
| cv.COLOR_BGR2BGRA | cv.COLOR_BGRA2BGR | なし | 0% | 0 | |
| cv.COLOR_BGR2HSV | cv.COLOR_HSV2BGR | あり | 0.43793403% | 0.00000012 | 0.00003102 | 
| cv.COLOR_BGR2Lab | cv.COLOR_Lab2BGR | あり | 99.66080729% | 0.00098905 | 0.25220869 | 
| cv.COLOR_BGR2Luv | cv.COLOR_Luv2BGR | あり | 3.40386285% | 0.00005605 | 0.01429347 | 
| cv.COLOR_BGR2HLS | cv.COLOR_HLS2BGR | あり | 0.00792101% | 0.00000023 | 0.00005921 | 
| cv.COLOR_BGR2YUV | cv.COLOR_YUV2BGR | あり | 52.72493490% | 0.00002682 | 0.00683790 | 
| cv.COLOR_BGR2XYZ | cv.COLOR_XYZ2BGR | あり | 0.33279080% | 0.00000020 | 0.00005223 | 
| cv.COLOR_BGR2YCrCb | cv.COLOR_YCrCb2BGR | あり | 44.03526476% | 0.00002096 | 0.00534422 | 
8bit、32bitどちらもBGRにアルファチャンネルを追加する変換のみ、変換前後の差異がありませんでした。既存のチャンネルに対しての操作が一切ないために差異が発生しなかったと考えられます。
それ以外の変換に関しては、少なからず差異が発生しているため、不可逆の変換だと言えます。ただし、8bitのときと32bitのときでは明らかに差異の大きさが異なりました。
結果として、色空間の変換をする際には8bit,32bitどちらも基本的に不可逆だと考えて画像を扱うべきですが、多少の誤差を許容できる場合は32bit画像での逆変換を行うことで誤差を最小限に抑えることが可能です。
さいごに
今回は、「PythonでOpenCVを使った色空間の変換」について解説しました。
それでは引き続きよろしくお願いいたします。
目次は以下の記事からご覧になれます。

