Python3 & OpenCV で画像処理を学ぶ[3] 〜 トーンカーブ と LUT を理解する実装実験
1. はじめに
本記事では、トーンカーブ(階調変換関数)を実装し、入力画像の濃淡やコントラストを補正する実験を行うことで、画素値の変換について学びます。
import numpy as np from matplotlib import pyplot as plt import matplotlib.gridspec as gridspec import seaborn as sns import cv2 # これを書くとjupyterでmatplotlibなどが出力する画像の解像度が上がる %config InlineBackend.figure_format = 'retina'
2. トーンカーブ基礎実験
2.1 トーンカーブの基礎
周知の通り、ディジタル画像の各画素(ピクセル)は、明暗やRGBなどの情報を表す画素値を持っています。そこで、入力の画素値 $x$ に対して出力の画素値 $y$ を対応づける関数 $f: x \rightarrow y \ (0 \leq x,y \leq 255)$ を与えることで、画像の濃淡を変換することができます。それをグラフで表したものがトーンカーブとなります。
具体的に見ていきましょう。プロットに使用した関数のコードは長くなるので、説明の都合上この節の最後に載せます。
2.1.1 例1: $y=x$
pdr = cv2.cvtColor(cv2.imread('pandra.png',1), cv2.COLOR_BGR2RGB) wiz = cv2.cvtColor(cv2.imread('Wizdomiot.png',1), cv2.COLOR_BGR2RGB)
def curve_1(x): y = x return y plot_curve(curve_1,pdr)
関数が $f(x)=x$, つまりトーンカーブのグラフが $y=x$ だと, 入力値がそのまま出力値となるので, 変化がありません。
2.1.2 例2: S字トーンカーブ
def curve_2(x): y = (np.sin(np.pi * (x/255 - 0.5)) + 1)/2 * 255 return y plot_curve(curve_2,pdr)
このような形状のトーンカーブは俗にS字トーンカーブと言われており、 input=127 を境として、低い入力値(暗部)はより低い出力値に、高い入力値(明部)はより高い出力値に写されています。結果として暗部・明部が強調され、出力画像を見るとコントラストが高くなっています。
ところで、コントラストの高さの定量的な特徴として、ヒストグラムが平坦であることがしばしば挙げられるようです。すなわち、コントラストが高い画像は、(写真などのように各画素値を幅広く含む画像であれば、)暗部から明部までなるべく満遍なく画素値があるということです。
実際に、上のS字トーンカーブによる入出力のヒストグラムを見比べてみると、特に入力では少なかった暗部の方に、出力のヒストグラムの分布が広がっていることがわかります。
このことから、トーンカーブを人手で描かなくとも、画像全体または画像を小領域に分割し、その中のヒストグラムの最小値・最大値を両端に引き延ばすようにしてコントラストを補正するアルゴリズミックな方法があります(ヒストグラム平坦化 Histogram equalization。 OpenCV-Python Tutorials のコントラスト平坦化の章を参照)。これによって、データ解析の前処理などとして、異なる照明環境で撮影された画像群のコントラストを自動で調整することができるようです。
2.1.3 例3: ガンマ変換
def curve_gamma1(x): gamma = 2 y = 255*(x/255)**(1/gamma) return y def curve_gamma2(x): gamma = 1/2 y = 255*(x/255)**(1/gamma) return y plot_curve(curve_gamma1,pdr) plot_curve(curve_gamma2,pdr)
このような変換は下記の関数で表され、ガンマ変換(ガンマ補正)と呼ばれています。
$y = 255\bigg(\dfrac{x}{255}\bigg)^{\frac{1}{\gamma}}$
$\gamma > 1$ のときは1枚目($\gamma = 2$)のように上に凸なグラフになり、すなわち全画素値が持ち上げられて画像は明るくなります。$\gamma < 1$ならばその逆で、2枚目($\gamma = \frac{1}{2}$)のようになります。
2.1.4 使用したコード
上記のグラフ描画のために書いた関数のコードが以下です。
def rgb_hist(rgb_img, ax, ticks=None): """ rgb_img と matplotlib.axes を受け取り、 axes にRGBヒストグラムをplotして返す """ color=['r','g','b'] for (i,col) in enumerate(color): hist = cv2.calcHist([rgb_img], [i], None, [256], [0,256]) hist = np.sqrt(hist) ax.plot(hist,color=col) if ticks: ax.set_xticks(ticks) ax.set_title('histogram') ax.set_xlim([0,256]) return ax def plot_curve(f,rgb_img): """ 関数 f:x->y, 画像 を受け取って 以下のようなグラフを出す ---------------------------- 入力画像 | Curve | 出力画像 histgram | | histgram ---------------------------- """ fig = plt.figure(figsize=(15,5)) gs = gridspec.GridSpec(2,3) x = np.arange(256) # トーンカーブを真ん中に sns.set_style('darkgrid') ax2 = fig.add_subplot(gs[:,1]) ax2.set_title('Tone Curve') ticks = [0,42,84,127,169,211,255] ax2.set_xlabel('Input') ax2.set_ylabel('Output') ax2.set_xticks(ticks) ax2.set_yticks(ticks) ax2.plot(x, f(x)) # 入力画像を←に, 出力画像を→に sns.set_style('ticks') ax1 = fig.add_subplot(gs[0,0]) ax1.set_title('input image →') ax1.imshow(rgb_img) ax1.set_xticks([]), ax1.set_yticks([]) # 目盛りを消す # 画素値変換。uint8で渡さないと大変なことに out_rgb_img = np.array([f(a).astype('uint8') for a in rgb_img]) ax3 = fig.add_subplot(gs[0,2]) ax3.set_title('→ output image') ax3.imshow(out_rgb_img) ax3.set_xticks([]), ax3.set_yticks([]) #入力と出力のヒストグラム sns.set_style(style='whitegrid') ax4 = fig.add_subplot(gs[1,0]) ax4 = rgb_hist(rgb_img, ax4, ticks) ax5 = fig.add_subplot(gs[1,2]) ax5 = rgb_hist(out_rgb_img, ax5, ticks) plt.show()
2.2 LUT(Look Up Table)による高速化
2.2.1 LUTとは?
先ほどまでに、入力と出力の画素値の対応づける関数を与えることで、画素値を変換しコントラストなどを補正できることを見てきました。
ところで、一般に画像の画素数は縦×横の分だけあり、フルHD(1920×1080)であれば2073600個の画素があります。フルHDの画像の階調変換を一回行おうとすると、素朴に考えて変換の計算を2073600回行わねばならないことになります。RGB各チャンネルに変換処理を行おうとすると、その3倍になります。計算量がちょっと気がかりになりますね。
しかしよく考えてみると、入力も出力も、取りうる値は 0~255 の整数であるため、画素値変換の対応は高々 256 通りです。とすると、いちいち変換の計算を行うのではなくて、「0~255 の各入力値に対して、出力値が 0~255 のうちどれに対応するか」を示す表をあらかじめ作っておいて、変換時はそれを参照すれば早そうです。表の作成自体は256回の計算で済みます。
そのような表のことをLUT(Look Up Table)と呼びます。掛け算九九をいちいち計算せずに覚えておくようなものですね。以下がそのイメージです。
入力 | 変換 | 出力 |
---|---|---|
0 | → | 0 |
1 | → | 2 |
… | … | … |
127 | → | 180 |
… | … | … |
255 | → | 255 |
2.2.2 LUTの実装
以下に、先ほどのようにピクセルごとに計算する実装と、LUTを用いるように書き換えた実装を示します。
def naive_curve(f,rgb_img): # 先ほどと全く同じ変換方式 out_rgb_img = np.array([f(a).astype('uint8') for a in rgb_img]) return out_rgb_img def LUT_curve(f,rgb_img): """ Look Up Tableを LUT[input][0] = output という256行の配列として作る。 例: LUT[0][0] = 0, LUT[127][0] = 160, LUT[255][0] = 255 """ LUT = np.arange(256,dtype='uint8').reshape(-1,1) LUT = np.array([f(a).astype('uint8') for a in LUT]) out_rgb_img = cv2.LUT(rgb_img, LUT) return out_rgb_img def simpleplot(rgb_img): sns.set_style('ticks') plt.title('output image') plt.imshow(rgb_img) plt.show()
img = naive_curve(curve_gamma1, wiz) simpleplot(img)
先ほどと同じ、画素ごとに変換を計算したものです。
img = LUT_curve(curve_gamma1, wiz) simpleplot(img)
こちらがLUTを用いた変換で、先ほどと全く同様に変換できていることがわかります。
2.2.3 LUTの速度性能
さて、素朴に各ピクセルを変換するのと、先にLUTを作ってから変換するのとではどれだけの速度差が出るのでしょうか。計測してみましょう。
【実験1】
#先ほどのS字トーンカーブ
%timeit naive_curve(curve_2, wiz)
%timeit LUT_curve(curve_2, wiz)
52.4 ms ± 3.77 ms per loop (mean ± std. dev. of 7 runs, 10 loops each) 4.11 ms ± 46.5 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
【実験2】
#ガンマ変換
%timeit naive_curve(curve_gamma1, wiz)
%timeit LUT_curve(curve_gamma1, wiz)
22.5 ms ± 1.66 ms per loop (mean ± std. dev. of 7 runs, 100 loops each) 2.95 ms ± 95.6 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
明確に速度の違いが出ました。コードを見ての通り、LUTを作成するところまで含めての速度差です。LUTを用いることで、S字トーンカーブでは約12.7倍、ガンマ変換では約7.6倍もの高速化を達成することができました。
S字トーンカーブの方の実装では三角関数を利用していたりするのでそもそもあまりよくないのですが、LUTによる変換の場合テーブル自体はすぐ作成できるため、変換に用いる関数が多少複雑なものであったりしたときも、あまり速度に差が出ないのがよいですね。
3. おわりに
3.1 今後の課題
今回触れられなかった点として、numpy.array(dtype='uint8')
は256で割った和の値であるようです。つまり、255を超えた(オーバーフローした)値は0から上がってきて、0を下回った値は255から降りてくるということです。 numpy の問題ではなく、 int 型なら一般に同じような挙動になるものと思います。
つまり例えば、トーンカーブなどで255を上回る値を出してしまったとき、通常我々が期待するのは255でストップすることだと思われるので、どこかでそのようにクリッピングする処理を考える必要があります。階調変換関数(トーンカーブ)の段階でそのようにするのがおそらく正しそうです。
3.2 まとめと次回について
トーンカーブとLUTの実装実験を通して、画素値を変換する際の基本的な考え方を知ることができました。
次回↓は、複数の画像の入力に対する処理について扱います。