【rawpy】PythonでRAW現像 -その3 :基本現像処理編-

カメラ・信号処理

この記事にはプロモーションが含まれています。

前回の記事では、現像の下準備としてRAWファイルのExif情報の読み取り方法を確認しました。

今回はいよいよ現像処理を行っていきます。

動作環境

動作環境はrawpy導入時と変わってませんが、一応記載しておきます。

  • OS : Windows11
  • Python 3.10.7

RAW現像処理フロー

最もシンプルなRAWの現像処理フローを以下に示します。

Exif情報を読み取りながら、この処理を施すコードを以下に示します。

一覧性をよくするために、平書きで書いてます。

以降で各処理について解説していきます。

import rawpy
import cv2
import os
from argparse import ArgumentParser
import numpy as np

# --- Options ---
# Commandline arg.
def get_option():
    argparser = ArgumentParser()
    argparser.add_argument('--in_raw_img', type=str, default = ".\\test.ARW", help='')
    argparser.add_argument('--out_dir', type=str,default = ".\\", help='')
    return argparser.parse_args()

# Save image file
def save_img(filename, in_img):
    in_img[in_img < 0] = 0
    in_img = ((in_img / np.max(in_img)) * 255).astype('uint8')
    out_img_bgr = cv2.cvtColor(in_img, cv2.COLOR_RGB2BGR)
    cv2.imwrite(filename, out_img_bgr)
    return 0

# --- Image processing ---
# Black level
def black(in_img, black_level, white_level):
    in_img[in_img < black_level] = black_level
    in_img[in_img > white_level] = white_level
    out_img = in_img - black_level
    return out_img

# Demosic
def demosic(in_img):
    in_img[in_img < 0] = 0
    img_r  = in_img[0::2, 0::2]
    img_gr = in_img[1::2, 0::2]
    img_gb = in_img[0::2, 1::2]
    img_b  = in_img[1::2, 1::2]
    img_g = ((img_gr + img_gb) / 2)
    out_img = np.dstack((img_r, img_g, img_b))
    return out_img

# White balance
def white_balance(in_img, white_balance_array):
    in_img[in_img < 0] = 0
    h, w, c = in_img.shape
    out_img = np.zeros((h, w, c))
    out_img[:, :, 0] = white_balance_array[0] * in_img[:, :, 0]     # R
    out_img[:, :, 1] = white_balance_array[1] * in_img[:, :, 1]     # G
    out_img[:, :, 2] = white_balance_array[2] * in_img[:, :, 2]     # B
    return out_img 
    
# color matrix
def color_matrix(in_img, color_matrix_array):
    in_img[in_img < 0] = 0
    h, w, c = in_img.shape
    out_img = np.zeros((h, w, c))
    out_img[:, :, 0] = color_matrix_array[0, 0] * in_img[:, :, 0] + color_matrix_array[0, 1] * in_img[:, :, 1] + color_matrix_array[0, 2] * in_img[:, :, 2]  # R
    out_img[:, :, 1] = color_matrix_array[1, 0] * in_img[:, :, 0] + color_matrix_array[1, 1] * in_img[:, :, 1] + color_matrix_array[1, 2] * in_img[:, :, 2]  # G
    out_img[:, :, 2] = color_matrix_array[2, 0] * in_img[:, :, 0] + color_matrix_array[2, 1] * in_img[:, :, 1] + color_matrix_array[2, 2] * in_img[:, :, 2]  # B
    return out_img

# Gamma
def gamma(in_img, max_val):
    # normalization 
    in_img = in_img / max_val
    in_img[in_img < 0] = 0
    in_img[in_img > 1] = 1
    # gammma correction
    alpha = 1.09929683
    beta  = 0.01805397
    out_img = np.where(in_img < beta, 4.5 * in_img, alpha * (in_img ** 0.45) - (alpha - 1))
    return out_img


if __name__ == "__main__":
    args = get_option()
    raw_img_file = args.in_raw_img
    raw_img_filename = raw_img_file.rsplit('\\')[-1].split('.')[0]
    out_dir = args.out_dir
    os.makedirs(out_dir, exist_ok = True)
    out_filename = os.path.join(out_dir, raw_img_filename)
    print("Input file : ", raw_img_file)
    print("Output dir. : ", out_dir)

    # read raw
    raw = rawpy.imread(raw_img_file)
    img_raw = raw.raw_image.copy()
    save_img(out_filename + "_out_00_raw.jpg", img_raw)

    # black level
    print("--- Black Level ---")
    print("black_level : ", raw.black_level_per_channel)
    print("white_level : ", raw.camera_white_level_per_channel)
    black_level_val = raw.black_level_per_channel[0]
    white_level_val = raw.camera_white_level_per_channel[0]
    img_black = black(img_raw, black_level_val, white_level_val)
    save_img(out_filename + "_out_01_black.jpg", img_black)

    # demosic
    print("--- Demosic ---")
    print("color_desc : ", raw.color_desc)
    print("raw_pattern : \n", raw.raw_pattern)
    img_demosic = demosic(img_black)
    save_img(out_filename + "_out_02_demosic.jpg", img_demosic)

    # white balance
    print("--- White Balance ---")
    white_balance_array = raw.camera_whitebalance / np.min(raw.camera_whitebalance)
    print("white_balance : ", raw.camera_whitebalance, " -> ", white_balance_array)
    img_wb = white_balance(img_demosic, white_balance_array)
    save_img(out_filename + "_out_03_wb.jpg", img_wb)

    # Color matrix
    print("--- Color Matrix ---")
    print("color_matrix_rawpy : \n", raw.color_matrix)
    exif_color_matri = np.array([1177, -211, 91, -54, 1267, -159, 72, -232, 1216]) / 1024
    color_matrix_array = exif_color_matri.reshape((3, 3))
    print("color_matrix_exif : \n", color_matrix_array)
    img_cm = color_matrix(img_wb, color_matrix_array)
    save_img(out_filename + "_out_04_cm.jpg", img_cm)

    # gamma
    print("--- Gamma ---")
    img_gamma = gamma(img_cm, white_level_val)
    save_img(out_filename + "_out_05_gamma.jpg", img_gamma)

補助的な関数

まずは、画像処理以外の補助的な関数から。

コマンドライン引数

9~13行目のget_option関数は、コマンドライン引数で読み出すRAW画像ファイルと処理後の画像ファイルの保存先を指定してます

def get_option():
    argparser = ArgumentParser()
    argparser.add_argument('--in_raw_img', type=str, default = ".\\test.ARW", help='')
    argparser.add_argument('--out_dir', type=str,default = ".\\", help='')
    return argparser.parse_args()

コマンドライン引数の使い方の解説は以下の記事で行ってますので、気になる方はご参照ください。

なお、このプログラムではmain関数内では先頭のファイル操作部分で使ってます。

if __name__ == "__main__":
    args = get_option()
    raw_img_file = args.in_raw_img
    raw_img_filename = raw_img_file.rsplit('\\')[-1].split('.')[0]
    out_dir = args.out_dir

画像保存

16~21行目のsave_img関数は、画像保存を行います。

def save_img(filename, in_img):
    in_img[in_img < 0] = 0
    in_img = ((in_img / np.max(in_img)) * 255).astype('uint8')
    out_img_bgr = cv2.cvtColor(in_img, cv2.COLOR_RGB2BGR)
    cv2.imwrite(filename, out_img_bgr)
    return 0

jpegなどの一般的な画像フォーマットは0~255の整数値で諧調が切られてます。

まずは、入力された配列に負の値があれば、クリップを行い、諧調を255で正規化しています。

また、本コードでは画像処理はカラーチャンネルの並びをRGB順の前提で行ってますが、opencvのカラーチャンネルの並びはBGR順です。

このため、opencvで画像保存するために、色配列をRGB→BGRに変更後に保存処理を行ってます。

画像処理の関数

それでは、本処理になる画像処理について、フロー順にコードを解説していきます。

main関数の先頭で画像処理指定後、85~88行目でrawpyの関数を使って、RAW画像データを読み出してます。

    # read raw
    raw = rawpy.imread(raw_img_file)
    img_raw = raw.raw_image.copy()
    save_img(out_filename + "_out_00_raw.jpg", img_raw)

この後の画像処理結果と比較するために、このRAW画像を一度保存して、次の処理に移行します。

黒レベル補正

6~21行目のblack関数は、黒レベル補正を行います。

def black(in_img, black_level, white_level):
    in_img[in_img < black_level] = black_level
    in_img[in_img > white_level] = white_level
    out_img = in_img - black_level
    return out_img

画像信号には黒レベルと言われるオフセット成分が乗っているので、これを除去してます。

また、無いとは思いますが、一応、飽和よりも大きな信号をクリップ処理してます。

main関数内では、RAW画像に埋め込まれた黒レベルと飽和レベルを使って、黒レベル補正処理をしてます。

    # black level
    print("--- Black Level ---")
    print("black_level : ", raw.black_level_per_channel)
    print("white_level : ", raw.camera_white_level_per_channel)
    black_level_val = raw.black_level_per_channel[0]
    white_level_val = raw.camera_white_level_per_channel[0]
    img_black = black(img_raw, black_level_val, white_level_val)
    save_img(out_filename + "_out_01_black.jpg", img_black)

今回のRAWでは、黒レベル・飽和レベルは以下だったようです。

--- Black Level ---
black_level :  [512, 512, 512, 512]
white_level :  [15360, 15360, 15360, 15360]

では処理前後の画像を確認してみます。

RAW画像
黒レベル補正後

これはオフセット成分を削っただけなので、画像の変化はあまりないです。

デモザイク (簡易)

32~40行目のdemosic関数は、デモザイク処理を行います。

def demosic(in_img):
    in_img[in_img < 0] = 0
    img_r  = in_img[0::2, 0::2]
    img_gr = in_img[1::2, 0::2]
    img_gb = in_img[0::2, 1::2]
    img_b  = in_img[1::2, 1::2]
    img_g = ((img_gr + img_gb) / 2)
    out_img = np.dstack((img_r, img_g, img_b))
    return out_img

デモザイクとは、白黒の単一チャンネルの配列をRGBの3chの配列に置き換える処理のことを言います。

RAW画像は何も処理がされてないので、カラーチャンネルが存在しません。

実際、適当な領域を拡大するとモザイク状の模様が見えます。

これはイメージセンサのカラーフィルターの配列が見えたものです。

RGGBのBayer配列の場合、R:G:Bは1:2:1の比率で色がイメージセンサ上に分布しており、2×2画素を最小単位として、以下のように配置されてます。
(Gは2個あるので、便宜上R行のGをGr、B行のGをGbとここでは呼ぶ)

この単一チャンネルの画像をRGBの3チャンネルに紐解く必要があるということです。

様々な技法がありますが、ここでは最も簡易的な方法として、2×2画素からR,B,Gをそれぞれ抽出することとします。

R,Bは何もせずそのままの値を、GはGrとGbの平均値を採用します。

今回使っているdemosic関数では色の並びが決め打ちの処理になっているので、Main関数ではraw_patternのメソッドを使って色の並びがRGGBかを確認できるようにしてます。

    # demosic
    print("--- Demosic ---")
    print("color_desc : ", raw.color_desc)
    print("raw_pattern : \n", raw.raw_pattern)
    img_demosic = demosic(img_black)
    save_img(out_filename + "_out_02_demosic.jpg", img_demosic)

記述順が特殊な気もしますが、RGGBの配列で問題ないみたいです。

--- Demosic ---
color_desc :  b'RGBG'
raw_pattern :
 [[0 1]
 [3 2]]

では処理前後の画像を確認してみます。

黒レベル補正後
デモザイク後

色味がガラッと変わります。緑っぽく見えるのは、Gの画素の感度がR,Bより高いからでしょう。

また、このデモザイク処理は超簡易版のため、解像度が1/4に落ちます。

ホワイトバランス補正

43~50行目のwhite_balance関数は、デモザイク処理を行います。

def white_balance(in_img, white_balance_array):
    in_img[in_img < 0] = 0
    h, w, c = in_img.shape
    out_img = np.zeros((h, w, c))
    out_img[:, :, 0] = white_balance_array[0] * in_img[:, :, 0]     # R
    out_img[:, :, 1] = white_balance_array[1] * in_img[:, :, 1]     # G
    out_img[:, :, 2] = white_balance_array[2] * in_img[:, :, 2]     # B
    return out_img 

デモザイクで見たように、イメージセンサの特性そのままでは人間が見たときにキレイな写真にはなりません。

そこで、RGBの信号比率を黒~白を表現するのに適切な比率に調整します。

$$\begin{bmatrix} R_o \\ G_o \\B_o \end{bmatrix} = \begin{bmatrix}Gain_R \\ Gain_G \\ Gain_B \end{bmatrix} \begin{bmatrix} R_i \\ G_i \\B_i \end{bmatrix} $$

この処理をホワイトバランス補正と言います。

こちらもいろいろな手法がありますが、main関数側ではExif情報をそのまま使ってしまいます。

この際に、一点注意点として、Exifは整数値しか埋め込まれていないので、Gの補正値が1以外の数字が入ってしまってます。

通常、GはそのままでR,Bにゲインをかけにいくため、Gの値が1になるように正規化してあげます。

    # white balance
    print("--- White Balance ---")
    white_balance_array = raw.camera_whitebalance / np.min(raw.camera_whitebalance)
    print("white_balance : ", raw.camera_whitebalance, " -> ", white_balance_array)
    img_wb = white_balance(img_demosic, white_balance_array)
    save_img(out_filename + "_out_03_wb.jpg", img_wb)

Rに強めのゲインをかけているようです。

--- White Balance ---
white_balance :  [2228, 1024, 1848, 1024]  ->  [2.17578125 1.         1.8046875  1.        ]

では処理前後の画像を確認してみます。

デモザイク後
ホワイトバランス補正後
ホワイトバランス補正後
(飽和レベルでクリップ)

ホワイトバランス補正をかけた後だと、画像が赤みがかるようになりました。

これはRに大きくゲインがかかり、飽和レベルを超えた状態で画像化したためです。

お試しで、補正後の画像に飽和レベルでクリップする処理を加えてみると、だいぶんそれっぽくなってきました。

    img_wb_clip = img_wb.copy()
    img_wb_clip[img_wb_clip > white_level_val] = white_level_val
    save_img(out_filename + "_out_03_wb_clip.jpg", img_wb_clip)

カラーマトリックス補正

53~60行目のcolor_matrix関数は、カラーマトリックス補正処理を行います。

def color_matrix(in_img, color_matrix_array):
    in_img[in_img < 0] = 0
    h, w, c = in_img.shape
    out_img = np.zeros((h, w, c))
    out_img[:, :, 0] = color_matrix_array[0, 0] * in_img[:, :, 0] + color_matrix_array[0, 1] * in_img[:, :, 1] + color_matrix_array[0, 2] * in_img[:, :, 2]  # R
    out_img[:, :, 1] = color_matrix_array[1, 0] * in_img[:, :, 0] + color_matrix_array[1, 1] * in_img[:, :, 1] + color_matrix_array[1, 2] * in_img[:, :, 2]  # G
    out_img[:, :, 2] = color_matrix_array[2, 0] * in_img[:, :, 0] + color_matrix_array[2, 1] * in_img[:, :, 1] + color_matrix_array[2, 2] * in_img[:, :, 2]  # B
    return out_img

ホワイトバランスでは黒白を正しくしましたが、今度は色を正しくする処理をします。

イメージセンサの出力にはRGBが含まれていますが、このRGBが人間が感じる主観的なRGBと一致するとは限りません。

そこで、イメージセンサのRGBをいい感じにブレンドして、人間の主観のRGBに置き換える調整を行っているのがカラーマトリックス補正になります。

$$ \begin{bmatrix}R_o \\G_o \\ B_o \end{bmatrix}=\begin{bmatrix}Coeff._{RR} & Coeff._{RG} & Coeff._{RB} \\ Coeff._{GR} & Coeff._{GG} & Coeff._{GB} \\ Coeff._{BR} & Coeff._{BG} & Coeff._{BB} \end{bmatrix}\begin{bmatrix}R_i \\G_i \\ B_i \end{bmatrix} $$

演算の係数の良し悪しは人間の主観に依存します。

このため、合わせこみのパラメータという扱いとなり、どのように落とし込んでいくかは各社でノウハウがあります。

一般的な求め方は、RGBの色空間のままでは人間の主観値との乖離があるため、演算する色空間を操作するかから始まります。

このあたりの話は込み入ってくるのでここでは割愛しますが、触りを知りたい方は以下のサイトが参考になるかもです。

また、詳しく知りたい方は、こちらの書籍を参照してみてください。2001年出版と少し古いですが、良書です。

ここでは処理による画像の変化を簡単に確認するために、main関数側ではExif情報をそのまま使ってしまいます。

ただ、rawpyで読み取れるカラーマトリックス係数は空でした。

そこで、exiftoolを使って読み取ったカラーマトリックス係数を使ってます。

    # Color matrix
    print("--- Color Matrix ---")
    print("color_matrix_rawpy : \n", raw.color_matrix)
    exif_color_matri = np.array([1177, -211, 91, -54, 1267, -159, 72, -232, 1216]) / 1024
    color_matrix_array = exif_color_matri.reshape((3, 3))
    print("color_matrix_exif : \n", color_matrix_array)
    img_cm = color_matrix(img_wb, color_matrix_array)
    save_img(out_filename + "_out_04_cm.jpg", img_cm)

ちなみに、処理の入力画像はホワイトバランスをかけた直後の配列です。

これはカラーマトリックス補正の過程で、飽和を超えた信号量も使う可能性があるためです。

--- Color Matrix ---
color_matrix_rawpy :
 [[0. 0. 0. 0.]
 [0. 0. 0. 0.]
 [0. 0. 0. 0.]]
color_matrix_exif :
 [[ 1.14941406 -0.20605469  0.08886719]
 [-0.05273438  1.23730469 -0.15527344]
 [ 0.0703125  -0.2265625   1.1875    ]]

では処理前後の画像を確認してみます。

ホワイトバランス補正後
カラーマトリックス補正後
カラーマトリックス補正後
(飽和レベルでクリップ)

ホワイトバランスのときと同様に、カラーマトリックス補正でも画像が赤みがかってます。

原因はホワイトバランスのときと同じなので、お試しで補正後の画像に飽和レベルでクリップする処理を加えてみます。

被写体の色の数があまりないためか分かりづらいところありますが、より色がくっきり分かるようになった感じがします。

ガンマ補正

63~72行目のgamma関数は、ガンマ補正を行います。

def gamma(in_img, max_val):
    # normalization 
    in_img = in_img / max_val
    in_img[in_img < 0] = 0
    in_img[in_img > 1] = 1
    # gammma correction
    alpha = 1.09929683
    beta  = 0.01805397
    out_img = np.where(in_img < beta, 4.5 * in_img, alpha * (in_img ** 0.45) - (alpha - 1))
    return out_img

ディスプレイの特性に合わせて、諧調表現カーブの補正を行う処理になります。

まずはマイナスの値や飽和を超えた信号をクリップ処理で落としていき、信号量を1で正規化します。

この後の処理として最も簡易なものは(1/2.2)乗をとるになりますが、今回はRec.2020という規格に則ってみました。

$$ E’ = \left\{
\begin{array}{l l}
4.5E & \quad \text{0 < E < }\beta\\
\alpha E^{0.45}-(\alpha -1) & \quad \beta\text{ < E < 1 }\
\end{array}
\right.$$

Rec. 2020 - Wikipedia

main関数側の処理は、このgamma関数にカラーマトリックス後の画像と飽和信号を入力するだけです。

    # gamma
    print("--- Gamma ---")
    img_gamma = gamma(img_cm, white_level_val)
    save_img(out_filename + "_out_05_gamma.jpg", img_gamma)

では処理前後の画像を確認してみます。

カラーマトリックス補正後
ガンマ補正後

違和感の少ない写真になりました。

まとめ

かなり簡易的にですが、これで現像できました。

RAW画像
黒レベル補正後
デモザイク後
ホワイトバランス補正後
カラーマトリックス補正後
ガンマ補正後

ただ、カメラ現像と比較すると、色味、ディテールともにまだまだな印象になります。

今回の簡易現像
カメラ内現像

実際の現像処理はもっと賢い処理がたくさん入ってますが、ここで紹介した処理は基本にあたります。

現像処理をより深く学習するにあたって、導入になればと思います。

手元での処理再現にあたっては、こちら関連記事も参照してみてください。

コメント

タイトルとURLをコピーしました