色見本を用いた画像の色調補正技術

撮影環境の光の色の影響を受けて撮影された画像の多くは、人の視覚がとらえる色とは異なる色味が付いてしまいます。例えば、蛍光灯を光源とすれば青っぽくなり、晴れた日の太陽を光源とすれば黄色っぽくなります。この記事は、そのような画像から色味の偏りを取り除いて、実際の色に近い画像に補正する技術について詳しく解説します。

技術概要

この技術を用いると、例えば下のような全体的に赤みがかった画像から偏った色味を取り除くことができます。

補正前の画像
補正前の画像
補正後の画像
補正後の画像

概要

色の基準となる、白と黒で構成された色見本を設置して撮影します。

色味の偏りは、無彩色である白および黒のRGB値で知ることができます。R,G,Bそれぞれの値の差が大きいほど色味の偏りが強いことを示します。その差を補正値として、画像すべての画素のR,G,Bに対して加算および減算をして、RGB値を同じ値もしくは近い値に揃えることで、色味の偏りを補正します。

色見本の色情報画像

特長

  • 色見本と一緒に撮影するだけで手軽に利用できる
  • 白と黒の色情報だけで補正することができる

条件

  • 撮影では色見本を画角に収める必要があります。
  • 色見本は色調の基準となるもので、白と黒を含んだものを使用します。

色見本について

この記事でご紹介する処理では、次の規格で作成された色見本を使用しています。

色見本の仕様を記載した図

規格

色見本は白と黒を含み、画像から色見本の矩形を検出するために必要な白枠と、背景が白枠の近似色であっても色見本と干渉しないようにするための黒枠を持つ構成になっています。サイズは次のとおりです。

  • A.幅:420mm
  • B.高さ:297mm
  • C.白枠 太さ:40mm
  • D.黒枠 太さ:20mm
  • E.白色 高さ:137mm
  • F.白色/黒色 幅:100mm

白と黒の配置やサイズなどを変更した場合は、閾値など一部の処理を書き換えるだけで色見本を検出できるように柔軟性を持たせています。

注意点

色見本との撮影時に注意しなければならないことがあります。

次のことに配慮して撮影をおこなわなかった場合は、色見本の検出に失敗したり、色見本から正しい色情報が得られず期待通りの結果にならない可能性があります。

  • 色見本の一部が隠れないように配置すること
  • 色見本に影が映り込まないように撮影すること
  • 色見本に照明などの強い光を当てないように撮影すること

開発環境

  • OS:Windows10
  • 仮想環境:Anaconda Navigator 1.9.12
  • パッケージ:Python 3.8.5、py-opencv 4.0.1、pillow 8.1.0、numpy 1.19.2

処理内容

まず画像から色見本を検出して、その色見本から得られた白と黒の色情報で補正値を求めて補正する、という流れになります。

それぞれの処理について詳しく解説します。

色見本検出

補正対象の画像から色見本を検出します。

色見本検出の精度を上げるために補正対象画像を検出対象の画素に絞り込んでから、色見本の面積に近いサイズの輪郭を検出し、その輪郭内の画素を射影変換で形を整えたあと、色見本かどうかの判定をおこないます。のちの補正処理で利用する色見本の色情報もここで取得します。

1. 色見本検出対象の画素を絞り込む

色見本の検出精度を上げるために、色見本の白枠の色に近似した色の画素を残した二値画像を作成します。

補正対象画像
補正対象画像
二値画像
二値画像
画像のリサイズ

のちの色見本を判定する処理で閾値として面積を利用するため、補正対象の画像はアスペクト比を固定して任意の幅でリサイズします。


# 幅3000pxでリサイズ
scale = 3000 / img.shape[1]
img_src = cv2.resize(img, dsize=None, fx=scale, fy=scale)
					
二値化

色見本の白枠の色に近似した色を閾値として、検出対象を絞り込んだ二値画像を作ります。


hsvLower = np.array([0, 0, 180])       # 抽出する色の下限(HSV)
hsvUpper = np.array([180, 35, 255])    # 抽出する色の上限(HSV)

# HSV で二値化
img_hsv = cv2.cvtColor(img_src, cv2.COLOR_BGR2HSV)
thresh = cv2.inRange(img_hsv, hsvLower, hsvUpper)
					
参考記事

ここまでの処理で、下図ような二値画像ができます。

二値画像

2. 色見本の面積に近い輪郭を検出

二値画像から輪郭を検出して、色見本の面積に近い輪郭の情報を取得します。

二値画像
二値画像
検出した輪郭線を補正対象画像に描画した画像
検出した輪郭線を補正対象画像に描画
輪郭検出

一番外側の輪郭を、点の数を極力減らして取得します。


cnts, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
					

注:OpenCV のバージョンが 3.x の場合は返り値が3つになるので、次のように記載します。


_, cnts, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
					
輪郭の絞り込み

すべての輪郭の面積を調べて、閾値より大きい輪郭のみを取得します。面積の取得方法は2通りあります。


cnt_list = []
for i in range(len(cnts)):
	M = cv2.moments(cnts[i])
	if M['m00'] > 5000.0:
		cnt_list.append(cnts[i])
		print(M['m00'])
					

もしくは


cnt_list = []
for i in range(len(cnts)):
	area = cv2.contourArea(cnts[i])
	if area > 5000.0:
		cnt_list.append(cnts[i])
		print(area)
					

コンソールには次のように出力され、閾値より大きい面積を持つ5つの輪郭に絞り込まれます。


14954.5
63986.5
11289.0
9120.0
9553.0
					
点の削減

のちに処理する射影変換のために、輪郭が持つ点情報の数を4つに絞る必要があります。ここでは検出したすべての輪郭から余分な点を減らします。

精度の高い近似と凸包の処理をしたあと、精度を下げてもう一度近似をおこなうことで、複雑な色や形状を含んだ画像から的確に輪郭の点情報を取得することができます。


# 近似
approx = cv2.approxPolyDP(cnt_list[i], 0.001 * cv2.arcLength(cnt_list[i], True), True)

# 凸包(与えられた点をすべて包含する最小の多角形)
approx = cv2.convexHull(approx)

# 近似(精度を下げて再度おこなう)
approx = cv2.approxPolyDP(approx, 0.08 * cv2.arcLength(approx, True), True)
					

最初の近似だけおこなった輪郭をマゼンタ色の線で描画すると下図のようになります。


次に、凸包をおこなうと下図のように点の数はかなり減りますが、これでもまだ多いので更に減らします。


最後に、精度を下げて再び近似をおこなうと、下図のように点の数をかなり減らすことができます。

ここまで検出した輪郭は、画像中央下段の色見本と右上の建物の一部です。建物の輪郭は誤検出ですが、のちの処理で取り除かれるので問題ありません。ここでは、色見本候補の輪郭をすべて検出することが目的です。


仮に、近似→凸法→近似の手順を踏まずに最後の近似だけをおこなった場合は、下図のように異なる結果になります。ここで右上の建物の一部の輪郭が4点で取得できなかったように、色見本候補の輪郭を取りこぼす可能性があります。

3. 色見本候補画素の取得

検出された輪郭が色見本であるかを判定するための前処理として、射影変換で正面から見た画像に補正します。

検出した輪郭線を補正対象画像に描画した画像
検出した輪郭線を補正対象画像に描画
輪郭内の画素を射影変換した画像
輪郭内の画素を射影変換
形状分類

射影変換をするためには、輪郭の点を4つにする必要があります。

まず10個以下の点を持つ輪郭に絞り込んでから、4つの点を持つ輪郭とそれ以外の点を持つ輪郭とで分類します。


approx_list =[]   # 座標
rect_list = []    # 4点の輪郭インデックス
other_list = []   # 4点以外の輪郭インデックス

if len(approx) <= 10:
	if len(approx) == 4:
		rect_list.append(i)
		approx_list.append(approx)
		print('4points')
	else:
		othrer_list.append(i)
		print('other')
				

コンソールには次のように出力され、4つの点を持つ輪郭が3つ、それ以外の点を持つ輪郭が2つ存在することが分かります。


4points
4points
4points
other
other
				
射影変換(4点の輪郭)

順番がバラバラになっている4点の座標を左下→左上→右下→右上の順に並べ替えて射影変換します。出力矩形サイズは任意で指定できますが、ここでは色見本の比率を保って縮小した 840x594px にしています。


img_list = []
width = 840
height = 594

# 射影変換(四角形)
for i in range(len(rect_list)):

	# numpy 配列を list に変換
	approx = approx_list[i].tolist()

	# x座標の小さい順に並べて4点を左右に分ける
	lef t = sorted(approx, key=lambda x:x[0]) [:2]
	right = sorted(approx, key=lambda x:x[0]) [2:]

	# y座標の小さい順に並べて上下に分ける
	left_down  = sorted(left, key=lambda x:x[0][1]) [0]
	left_up    = sorted(left, key=lambda x:x[0][1]) [1]
	right_down = sorted(right, key=lambda x:x[0][1]) [0]
	right_up   = sorted(right, key=lambda x:x[0][1]) [1]

	# 補正前の4点の座標
	perspective1 = np.float32([left_down, right_down, right_up, left_up])

	# 補正後の4点の座標(width, height は任意のサイズ)
	perspective2 = np.float32()

	# 射影変換行列を生成
	psp_matrix = cv2.getPerspectiveTransform(perspective1, perspective2)

	# 射影変換
	img_rect = cv2.warpPerspective(edit_img, psp_matrix,(width, height))

	img_list.append(img_rect)
					

3つの輪郭内の画素が、それぞれ下図のように射影変換されます。

3つの輪郭内の画素の射影変換後の画像
射影変換(4点以外の輪郭)

4点座標に変換してから、順番がバラバラになっている4点の座標を左下→左上→右下→右上の順に並べ替えて射影変換します。


for i in range(len(other_list)):

	# 輪郭に外接する回転した長方形を取得
	rect = cv2.minAreaRect(cnt_list[other_list[i]])
	
	# 4点座標に修正
	box = cv2.boxPoints(rect)
	box = np.int0(box)
	
	# numpy 配列を list に変換
	box = box.tolist()

	# x座標の小さい順に並べて4点を左右に分ける
	left  = sorted(box, key=lambda x:x[0]) [:2]
	right = sorted(box, key=lambda x:x[0]) [2:]

	# y座標の小さい順に並べて上下に分ける
	left_down  = sorted(left, key=lambda x:x[0]) [0]
	left_up    = sorted(left, key=lambda x:x[0]) [1]
	right_down = sorted(right, key=lambda x:x[0]) [0]
	right_up   = sorted(right, key=lambda x:x[0]) [1]
	
	# 出力矩形サイズを取得(取得する矩形の傾きによって計算に用いる座標を変える)
	if right_up[0] - left_up[0] > 0:
		width = right_up[0] - left_up[0]
	else:
		width = left_up[0] - right_up[0]
	if left_down[1] - left_up[1] > 0:
		height = left_down[1] - left_up[1]
	else:
		height = left_up[1] - left_down[1]
	
	# 補正前の4点の座標
	perspective1 = np.float32([left_down, right_down, right_up, left_up])

	# 補正後の4点の座標
	perspective2 = np.float32()

	# 射影変換行列を生成
	psp_matrix = cv2.getPerspectiveTransform(perspective1, perspective2)

	# 射影変換
	img_rect = cv2.warpPerspective(edit_img, psp_matrix,(width, height))
	
	img_list.append(img_rect)
					
参考記事

4点以外の輪郭を4点座標に変換した輪郭をシアン色の線で描画すると下図のようになります。

4点以外の輪郭を4点座標に変換して補正対象画像に描画した画像

輪郭内の画素を射影変換すると下図のようになります。

2つの輪郭内の画素の射影変換後の画像

4. 色見本判定と色見本の色情報を取得

規定の縦横比で外周に白い枠があり、各色が規格内の色である場合に色見本と判定します。

各色の色情報は、白枠を取り除いたあと指定の座標から取得します。

色見本から色情報を取得するまでを説明する画像
判定1:縦横比率

色見本の縦横比率と異なる比率の場合は候補から除外します。


for i in range(len(img_list)):

	h, w = img_list[i].shape[:2]

	# 色見本の縦横比は 1.4 のため、ゆとりをもたせて判定する
	if 1.2 < (w / h) < 1.5:
		print('OK')
	else:
		print('NG')
		continue
					
判定2:白枠の有無

白枠の色に近い画素が色見本の幅ほど連続しているかで白枠と判定します。


# for i in range(len(img_list)):

	# 二値化
	img = cv2.cvtColor(img_list[i], cv2.COLOR_BGR2GRAY)
	ret, thresh = cv2.threshold(img, 100, 255, cv2.THRESH_BINARY)

	# 255 の連続最大数を求める
	max = 0
	for line in thresh:
		cnt = 0
		for i in line:
			if i > 0:
				cnt += 1
			if cnt > max:
				max = cnt

	# 連続最大数が色見本の幅に近ければ白枠とみなす
	if max > w * 0.95:
		print('OK')
	else:
		print('NG')
		continue
					

二値化や連続最大数の閾値の調整により、判定の厳密さを変えることができます。

色見本が極端に暗い場所や明るい場所に配置されていたり、一部が遮蔽物に隠れたり、斜めになるなど、実際には様々な影響によって下図のように明瞭に検出できない場合もあります。その場合は、環境に応じて閾値を調整していく必要があります。

判定3:色の構成

色見本の色が規格の構成かどうかを判定します。

まず、色見本画像に対する白枠の太さの比率を求めて、画像幅から白枠の幅を算出して白枠を消去します。


# for i in range(len(img_list)):

	ratio = 0.13
	h, w = img_list[i].shape[:2]
	frame = int(w * ratio)
	img_dst = img[frame: h - frame, frame: w - frame]
					
色見本候補画像と白枠を除去した画像との比較の画像

次に、各色の切り出す領域を求めて、切り出した画素の平均値を色情報として取得します。


# for i in range(len(img_list)):

	color = 3   # 色見本の色数(黒・白・黒)
	splitW = 8  # 切り出し領域の幅(1色の幅を何等分するか)
	splitH = 16 # 切り出し領域の高さ(1色の高さを何等分するか)
	margin = 20 # 境界は他の色の影響を受ける可能性があるためマージンをとる

	h, w = img_dst.shape[:2]
	cutoutW = int(w / (color * splitW)) + margin
	cutoutH = int(h / (splitH)) + margin
	stepX = int(w / color)

	# 切り出し開始位置設定
	cutoutStartX = cutoutW
	cutoutStartY = cutoutH

	# 色見本色情報の初期化
	color_sample_bgr = np.zeros((color, 3), dtype=np.uint8)
	color_sample_hsv = np.zeros((color, 3), dtype=np.uint8)

	# 1色ずつ切り出して平均値を取得
	for i in range(color):

		# 切り出し
		img_bgr = img[cutoutStartY: cutoutStartY + cutoutH, cutoutStartX: cutoutStartX + cutoutW]

		# BGR平均値を取得
		# flattenで一次元化しmeanで平均を取得
		color_sample_bgr[i][0] = int(img_bgr.T[0].flatten().mean())
		color_sample_bgr[i][1] = int(img_bgr.T[1].flatten().mean())
		color_sample_bgr[i][2] = int(img_bgr.T[2].flatten().mean())

		# BGRからHSVに変換
		img_hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)

		# HSV平均値を取得
		# flattenで一次元化しmeanで平均を取得
		color_sample_hsv[i][0] = img_hsv.T[0].flatten().mean()
		color_sample_hsv[i][1] = img_hsv.T[1].flatten().mean()
		color_sample_hsv[i][2] = img_hsv.T[2].flatten().mean()

		# 次の色の開始位置へ
		cutoutStartX += stepX
					

切り出し領域の指定座標で margin をとることで、近隣の色の影響を受けた画素を避けて取得しています。

色見本から1色ずつ切り出す説明の画像

そして、色見本の色が規格の構成(黒・白・黒)かどうかを判定します。閾値を調整することで厳密に判定することができます。


# for i in range(len(img_list)):

	# 左右の黒の色差
	diff = 0
	for i in range(3):
		diff += (abs(int(color_sample_bgr[0][i]) - int(color_sample_bgr[2][i])))

	# 色差(閾値は低いほど厳密)
	if diff > 18:
		print('NG')
		continue
	else:
		# 白と黒の色差
		diff = 0
		for i in range(3):
			diff += (abs(int(color_sample_bgr[0][i]) - int(color_sample_bgr[1][i])))
		# 色差(閾値は高いほど厳密)
		if diff < 250:
			print('NG')
			continue
		print('OK')
					

補正

画像全体の画素を閾値より明るい画素と暗い画素に分けて、それぞれ異なる補正値を用いて画素値を変更して、最後に合成します。

補正値は、検出した色見本の白と黒の色情報から求めます。白の補正値は明るい画素の補正に用いられ、黒の補正値は暗い画素の補正に用いられます。

1. 画素を明度で分類

画像全体の画素を明度による二値化で分類します。

二値化した結果、閾値を越える画素が画像全体の半分以上を占めていたら、閾値を上げて再度二値化します。明るい画素と暗い画素が均等になるまで、これを繰り返します。


# ニ値化の閾値初期設定
thresh = 128
max_thresh = 255

img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
h, w = img_gray.shape[:2]
size = h * w

# 二値化(閾値より大きい画素が全体の半分以上を占めていたら閾値を上げる)
while thresh < max_thresh:
	img_bool = img_gray > thresh
	hilight_sum = img_bool.sum()
	if hilight_sum > size / 2:
		thresh += 1
	else:
		break
				
参考記事

注:上記の img には補正対象の画像を指定します。色見本検出のためにリサイズした画像 img_src ではありません。

2. 補正

色見本の白から求めた補正値で明るい画素に加算して、色見本の黒から求めた補正値で暗い画素に減算することで、色味の偏りを取り除くことができます。加算と減算により画素値が桁あふれ(オーバーフロー/アンダーフロー)をおこさない工夫をしています。


# 色見本配列のインデックス
indexBlack = 0
indexWhite = 1

# 明るい画素の補正値
img_hi = np.full_like(img, 255)
w_max = max(color_sample_bgr[indexWhite])
bgr = (color_sample_bgr[indexWhite] < w_max)
bgr = bgr * (w_max - color_sample_bgr[indexWhite])
print('明るい画素の補正値:', bgr)

# 色差が大きい補正値になる場合は、画像全体を明るい画素のみにする
if max(bgr) - min(bgr) > 50:
	img_bool = np.full_like(img_bool, True)

# 補正値加算可能な最大BGR値
maxval = [255, 255, 255]
max_bgr = abs(bgr - maxval)
img_hi_bool = np.empty_like(img_hi)

# 補正値を加算しても 255 を越えない画素値のみ補正対象とする
for i in range(3):
	img_hi_bool[:,:,i] = img[:,:,i] <= max_bgr[i]

# 補正値を加算
for i in range(3):
	img_hi[:,:,i] += img_bool * img_hi_bool[:,:,i] * (img[:,:,i] + bgr[i])


# 暗い画素の補正値
img_sh = np.zeros_like(img)
b_min = min(color_sample_bgr[indexBlack])
bgr = (color_sample_bgr[indexBlack] > b_min)
bgr = bgr * (color_sample_bgr[indexBlack] - b_min)
print('暗い画素の補正値:', bgr)

# 色差が大きい補正値になる場合は、画像全体を暗い画素のみにする
if max(bgr) - min(bgr) > 50:
	img_bool = np.full_like(img_bool, False)

# 補正値減算可能な最小BGR値
minval = bgr
img_sh_bool = np.empty_like(img_sh)

# 補正値で減算しても 0 未満にならない画素値のみを補正対象とする
for i in range(3):
	img_sh_bool[:,:,i] = img[:,:,i] >= minval[i]

# 補正値を減算
for i in range(3):
	img_sh[:,:,i] = ~img_bool * img_sh_bool[:,:,i] * (img[:,:,i] - bgr[i])


# 合成
img_dst = img_sh.copy()
img_dst[:,:,0] += img_bool * img_hi[:,:,0]
img_dst[:,:,1] += img_bool * img_hi[:,:,1]
img_dst[:,:,2] += img_bool * img_hi[:,:,2]
				

コンソールには次のように表示されます。


明るい画素の補正値: [ 0 15  1]
暗い画素の補正値: [15  0 14]
				
明るい画素の画像
明るい画素
暗い画素の画像
暗い画素
合成した画像

合成すると、色味が補正された画像になります。

3. 調整

明るい画素と暗い画素それぞれを異なる補正値で補正したことによって生じる問題を、合成したあとの調整で補います。

明るさの調整

色見本の白のRGB値の最大値と黒のRGB値の最小値の差分を補正値として、明るさの調整をします。ここではコントラストの調整はしていません。


# 補正値
bright_up = int(255 - w_max)
bright_down = b_min
bright = int(bright_up - bright_down)

# 明るさ:beta、コントラスト:alpha
img_dst = adjust(img_dst, alpha=1.0, beta=0.392 * bright)
					
参考記事

色見本の画素値を比較すると若干の明るさの変化が分かります。この画像の場合は一見して違いが分かりにくいのですが、明るさ調整後の画像の方が若干明るく変化しています。色見本の画素値を比較すると、その変化が分かります。

明るさ調整前の画像
明るさ調整前
明るさ調整後の画像
明るさ調整後
明るさ調整前と調整後の比較画像
ぼかし

明るい画素と暗い画素の合成によってできてしまう境界を目立たなくする目的で、軽めの「ぼかし」をかけます。

細かい調整は必要ないので、ここでは標準偏差に 0 を指定して、カーネルサイズから標準偏差が自動計算されるようにしています。


# カーネル:(15, 15)、標準偏差:0
img_dst = cv2.GaussianBlur(img_dst, (1, 1), 0)
					

結果

以上の処理で、全体的に赤みがかった画像から偏った色味を取り除くことができました。

補正前の画像
補正前の画像
補正前の画像
補正後の画像

色見本の比較

色見本の画素値 R, G, B値が同じ値で揃っていることからも、色味が補正されていることが分かります。

色見本の色情報画像

用途

この色補正技術には様々な用途があると考えています。ここでは例として次の2つの用途をご紹介します。

経年劣化診断

例えば建物の外壁やブロック塀など、前後の色の変化を目視で診断したい時に、この色調補正技術を利用できます。

撮影した日の天候など、異なる環境下で撮影された画像の色味を補正することができるので、ほぼ同じ条件で前後の画像を比較することができます。

次のように、日を空けて撮影したと想定する A, B の画像をそれぞれ補正して、出力した画像を目視で比較して診断することができます。

経年劣化診断の画像

商品撮影

例えば商品撮影のような、色を正しく伝える撮影をしたい時に、この色調補正技術を利用できます。

白いものを白に近づけて、商品の色を正しく伝える画像に補正することができるので、撮影で通常おこなう照明やカメラの調整に気を遣う必要がありません。

補正前の画像
補正前の画像
補正前の画像
補正後の画像

まとめ

色見本を含めて撮影するだけなので、撮影環境を気にせず、またカメラの知識がなくても撮影できる手軽さがあります。

この技術をツール化すれば、撮影した画像を一括で補正できるので効率が上がります。また、コード内の閾値や処理の一部に変更を加えれば、様々な色見本に対応できる汎用性も持っています。

色見本を用いて撮影した画像を色調補正するこの技術は、取り上げた用途以外にも様々な分野で活用できると私どもは考えています。

今回は弊社にて開発した技術の一部を記事の掲載向けに抜粋してご紹介いたしました。弊社はシステム開発を会社として、パッケージソフトの販売ではなくお客様のニーズに合わせた”受託開発”を専門としています。本記事に記載された技術に関するご質問や、利活用のご相談などがございましたらお気軽にお問い合わせください。