【色検知】OpenCVで色情報から特定の物体を検出してみる

# 概要
今回はPythonとOpenCVを用いて画像の色情報から特定の物体を検出してみようと思います．この画像処理手法については様々なものがありますが，今回は定番である「元画像をマスクしてラベリングする手法」を用います♪  

# 実行環境
Python : Ver.3.10.2  
OpenCV : Ver.4.6.9

# 全体の流れ
全体の大まかな流れは次のようになります．これからそれぞれの処理について取り扱っていきますが，「はやく結果だけほしい！！」という方は４項の最後まで飛ばしてくださいね♪
１．画像をヒストグラム平坦化する  
２．RGB空間からHSV空間に移行させる　　
３．抽出したい色の閾値でマスクする  
４．ラベリングする  
５．まとめ  

# １．画像をヒストグラム平坦化する
## なぜヒストグラム平坦化をするの？？
まずはじめに元画像をヒストグラム平坦化します．これによって画素の輝度のヒストグラムを平らにして画像のコントラストを改善することができます．  
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298420/bd0abc98-5cc8-d3b7-d241-f53ca48fa1a8.jpeg" alt="ヒストグラム平坦化前の画像" width="300px">
上の画像に対して，ヒストグラム平坦化を施して輝度を分散させてあげると下のようになります．  
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298420/869d52d7-9d48-17a7-da23-5a132200774a.jpeg" alt="ヒストグラム平坦化後の画像" width="300px">
輝度が分散されてますね！この処理を加えることで逆光等で検知できなかった物体を検知できるようになることがあります．  
## ヒストグラム平坦化をやってみよう！！
それでは実際にヒストグラム平坦化をやってみます．これも様々な手法がありますが，ここではOpenCVの適用的ヒストグラム平坦化(CLAHE)を用います．  

https://docs.opencv.org/4.x/d6/db6/classcv_1_1CLAHE.html  

これは画像全体ではなく，画像を分割してヒストグラム平坦化することで適度に輝度を分配するものです．  
**RGB空間だと色成分ごとに補正されて色味が保持されないので輝度と色味を分けるためにRGBで表現されている画像をYCbCr（Y：輝度，Cb：青の色差，Cr：赤の色差）に変換**します．  
`img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV)`  
次にclaheオブジェクトを生成します．ここで，「clipLimit」は区分ごとの平坦化の際ノイズが目立たないようにコントラストを制限するもの，「tileGridSize」は分割する区分のサイズです．
`clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) `  
輝度に対してのみヒストグラム平坦化を適用します．
`img_yuv[:,:,0] = clahe.apply(img_yuv[:,:,0])`  
最後に元のRGB空間に戻します．
`img = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR)`  
以上をまとめた以下のコードで平坦化後の画像を取得できます．
```python:clahe.py
import cv2

def Clahe(img_name):
    img = cv2.imread(img_name) # 画像を読み込む

    img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV) # RGB => YUV(YCbCr)
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) # claheオブジェクトを生成
    img_yuv[:,:,0] = clahe.apply(img_yuv[:,:,0]) # 輝度にのみヒストグラム平坦化
    img = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR) # YUV => RGB
    cv2.imwrite("out_img.jpg", img) # 書き出す
    
    
if __name__ == '__main__':
    Clahe("test.jpg") # ファイル名を入力
```

::: note warn
画像によってはパラメータの調整が必要だったり，ほかの手法の方が適している場合もあります．  
:::

# ２．RGB空間からHSV空間に移行させる
画像は光の３原色である赤緑青で表現されていますが，このままだと抽出したい閾値を設定するのが困難です．RGB空間はいわば赤緑青それぞれの色を塗り合わせて色を表現しているのでプログラマーが「この色が欲しい！！」と思ってもそれをコンピュータに伝えるのが難しいです...  
そこで，RGB空間からHSV空間に移行させます．HSVとは，それぞれ色彩(Hue)，彩度(Saturation)，明度(Value Brightness)のことで画像はHSVでも表現できます．この色彩はいわば色相環のことで，「色相環でいう何度から何度までの色が欲しい！！」といった感じで求める色を指定することができます．**通常，HSVは色彩を0~360°，彩度と明度を0~100%で表現しますが，OpenCVでは色彩を0~180，彩度と明度を0~255で表現します（１バイトで表現できるデータ量に限りがあるからです）**．この相互変換にはネット上にあるツールを使用してみて下さい．試しに赤色をHSVで表現してみます．
```python:赤系のHSV表現
# 赤色は２つの領域にまたがります！！
# np.array([色彩, 彩度, 明度])
# 各値は適宜設定する！！
LOW_COLOR1 = np.array([0, 50, 50]) # 各最小値を指定
HIGH_COLOR1 = np.array([6, 255, 255]) # 各最大値を指定
LOW_COLOR2 = np.array([174, 50, 50])
HIGH_COLOR2 = np.array([180, 255, 255])
```
このように色によっては閾値が２つに分かれる場合があります．RGBからHSVに変換するには`hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)`とするだけです♪  

:::note
RGB画像とHSV画像は表現方法が違うだけで本質的には同じですが，無理やりHSV画像を表示させようとすると色味が変化するよ！
:::  

# ３．抽出したい色の閾値でマスクする
ここでは，ヒストグラム平坦化を施した画像から特定の色を摘出します．閾値は先程のものを使用してOpenCVの`inRange`関数を用いてHSVで表された画像を二値化します．  
`bin_img = cv2.inRange(hsv, LOW_COLOR, HIGH_COLOR)`  
閾値の領域が分かれる場合は足し合わせます．  
`mask = bin_img1 + bin_img2`  
作成したマスクと元画像の各画素のビット毎の論理積を求めることで任意の色だけを取り出すことができます！  
`masked_img = cv2.bitwise_and(img, img, mask= mask)`  
これらをまとめて，元画像から特定の色を取り出すプログラムは以下のようになります．
```python:color_detect.py
import cv2
import numpy as np

# 赤色は２つの領域にまたがります！！
# np.array([色彩, 彩度, 明度])
# 各値は適宜設定する！！
LOW_COLOR1 = np.array([0, 50, 50]) # 各最小値を指定
HIGH_COLOR1 = np.array([6, 255, 255]) # 各最大値を指定
LOW_COLOR2 = np.array([174, 50, 50])
HIGH_COLOR2 = np.array([180, 255, 255])

def Clahe(img_name):
    img = cv2.imread(img_name) # 画像を読み込む

    img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV) # RGB => YUV(YCbCr)
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) # claheオブジェクトを生成
    img_yuv[:,:,0] = clahe.apply(img_yuv[:,:,0]) # 輝度にのみヒストグラム平坦化
    img = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR) # YUV => RGB
    
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # BGRからHSVに変換
    
    bin_img1 = cv2.inRange(hsv, LOW_COLOR1, HIGH_COLOR1) # マスクを作成
    bin_img2 = cv2.inRange(hsv, LOW_COLOR2, HIGH_COLOR2)
    mask = bin_img1 + bin_img2 # 必要ならマスクを足し合わせる
    masked_img = cv2.bitwise_and(img, img, mask= mask) # 元画像から特定の色を抽出
    cv2.imwrite("out_img.jpg", masked_img) # 書き出す
    
    
if __name__ == '__main__':
    Clahe("test.jpg") # ファイル名を入力
```
先程の画像で試してみると...  
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298420/c42b6cbe-aff7-36a2-b58a-21ada1d8e8bf.jpeg" alt="赤色を検出した画像" width="300px">  
赤色を検出できましたね♪
以上の操作が元画像から求める色のみを抽出する方法です．これで色情報による画像処理ができました！！  

# ４．ラベリングする
## 連結領域毎にラベリングしていく
さて，特定の色を抽出できたので物体を検出できるようにしていきましょう！まずは色がつながっている領域を１つの物体とみなしてラベリングしていきます．これには連結領域を検出するOpenCVの`connectedComponentsWithStats`関数を使います．  

https://docs.opencv.org/3.4/d3/dc0/group__imgproc__shape.html#ga107a78bf7cd25dec05fb4dfc5c9e765f

必要なのはマスク部分の情報なので先程作成したマスクを渡します．  
`num_labels, label_img, stats, centroids = cv2.connectedComponentsWithStats(mask)`  
この関数は，ラベルの数とラベリング画像に加え，ブロブ（連結している領域），面積，重心の情報を出力してくれます♪ここで気を付けないといけないのは**画像の背景も１つのラベルとして認識してしまう**ことです．そのため，このラベルを削除して処理をします．  
stats（ラベル領域の情報）とcentroids（重心の情報）の中身は以下のようになっています．  
```python:statsとcentroidsの中身
stats = [
         [ x  y  w  h  s]   # １番大きいラベル領域の情報（背景のラベル情報）
         [ x  y  w  h  s]   # ２番目に大きいラベル領域の情報
         [ x  y  w  h  s]   # ３番目に大きいラベル領域の情報
          ...
        ]
centroids = [
         [ mx  my ]   # １番大きいラベルの重心の情報（背景の重心の情報）
         [ mx  my ]   # ２番目に大きいラベルの重心の情報
         [ mx  my ]   # ３番目に大きいラベルの重心の情報
          ...
        ]
```
背景のラベルを以下のように削除します．  
```python:背景のラベルを削除
num_labels = num_labels - 1
stats = np.delete(stats, 0, 0) 
centroids = np.delete(centroids, 0, 0)
```  
最後に，OpenCVの`rectangle`関数と`putText`関数を用いて各インデックスのラベル情報を画像に追記していきます．  
まとめると以下のようになります．  
```python:labelingAll.py
import cv2
import numpy as np

# 赤色は２つの領域にまたがります！！
# np.array([色彩, 彩度, 明度])
# 各値は適宜設定する！！
LOW_COLOR1 = np.array([0, 50, 50]) # 各最小値を指定
HIGH_COLOR1 = np.array([6, 255, 255]) # 各最大値を指定
LOW_COLOR2 = np.array([174, 50, 50])
HIGH_COLOR2 = np.array([180, 255, 255])

def labelingAll(img_name):
    img = cv2.imread(img_name) # 画像を読み込む

    img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV) # RGB => YUV(YCbCr)
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) # claheオブジェクトを生成
    img_yuv[:,:,0] = clahe.apply(img_yuv[:,:,0]) # 輝度にのみヒストグラム平坦化
    img = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR) # YUV => RGB
    
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # BGRからHSVに変換
    
    bin_img1 = cv2.inRange(hsv, LOW_COLOR1, HIGH_COLOR1) # マスクを作成
    bin_img2 = cv2.inRange(hsv, LOW_COLOR2, HIGH_COLOR2)
    mask = bin_img1 + bin_img2 # 必要ならマスクを足し合わせる
    masked_img = cv2.bitwise_and(img, img, mask= mask) # 元画像から特定の色を抽出
    
    out_img = masked_img
    num_labels, label_img, stats, centroids = cv2.connectedComponentsWithStats(mask) # 連結成分でラベリングする
    # 背景のラベルを削除
    num_labels = num_labels - 1
    stats = np.delete(stats, 0, 0)
    centroids = np.delete(centroids, 0, 0)
    
    # すべてのラベルを表示
    for index in range(num_labels):
        x = stats[index][0]
        y = stats[index][1]
        w = stats[index][2]
        h = stats[index][3]
        s = stats[index][4]
        mx = int(centroids[index][0]) # 重心のX座標
        my = int(centroids[index][1]) # 重心のY座標
        cv2.rectangle(out_img, (x, y), (x+w, y+h), (255, 0, 255)) # 各ラベルを四角で囲む
        cv2.putText(out_img, "%d,%d"%(mx, my), (x-15, y+h+15), cv2.FONT_HERSHEY_PLAIN, 1, (255, 255, 0)) # 重心を表示
        cv2.putText(out_img, "%d"%(s), (x, y+h+30), cv2.FONT_HERSHEY_PLAIN, 1, (255, 255, 0)) # 面積を表示
        
    cv2.imwrite("out_img.jpg", out_img) # 書き出す
    
    
if __name__ == '__main__':
    labelingAll("test.jpg") # ファイル名を入力
```
これも先程の画像に適用すると...  
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298420/1a5fbdbd-1de3-c764-db32-ac6311120b81.jpeg" alt="すべての連結領域にラベリングした画像" width="300px">  
それぞれの連結領域にラベリングされてますね！！いい感じになってきました♪  

## スムージングする  
さて，先程の画像ではノイズが多いので画像に平滑化処理を加えます．スムージング処理で画像をぼかすことで特徴量やエッジを調整できます．今回は画像をいくつかの領域に分けて各領域で平滑化する**blurフィルタ**を用います．  
`img_blur = cv2.blur(img, (5, 5))`  
`blur`関数には画像データを第1引数に，カーネルサイズを第２引数に渡します．指定したカーネルサイズごとの領域で平滑化処理を行うので領域を大きくするとぼかしが強くなります．わかりやすいように15×15のカーネルサイズでスムージングを行うと次のようになります．  
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298420/544b55d4-5038-4560-3781-236c729cb261.jpeg" alt="平滑化処理後の画像" width=300px>  
全体的に画像がぼけましたね！！この画像で先程のラベリングを行うと...  
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298420/d5f5fcd4-7f4d-936f-6198-41ad5a5616a4.jpeg" alt="スムージング後のラベリング" width=300px>  
明らかにノイズが減っています！先程のコードに１行追加しただけでフィルタリングができました！！  

<details>
<summary>ソースコード</summary>

```python:bulr.py
import cv2
import numpy as np

# 赤色は２つの領域にまたがります！！
# np.array([色彩, 彩度, 明度])
# 各値は適宜設定する！！
LOW_COLOR1 = np.array([0, 50, 50]) # 各最小値を指定
HIGH_COLOR1 = np.array([6, 255, 255]) # 各最大値を指定
LOW_COLOR2 = np.array([174, 50, 50])
HIGH_COLOR2 = np.array([180, 255, 255])

def labelingAll(img_name):
    img = cv2.imread(img_name) # 画像を読み込む

    img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV) # RGB => YUV(YCbCr)
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) # claheオブジェクトを生成
    img_yuv[:,:,0] = clahe.apply(img_yuv[:,:,0]) # 輝度にのみヒストグラム平坦化
    img = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR) # YUV => RGB
    
    img_blur = cv2.blur(img, (15, 15)) # 平滑化フィルタを適用
    
    hsv = cv2.cvtColor(img_blur, cv2.COLOR_BGR2HSV) # BGRからHSVに変換
    
    bin_img1 = cv2.inRange(hsv, LOW_COLOR1, HIGH_COLOR1) # マスクを作成
    bin_img2 = cv2.inRange(hsv, LOW_COLOR2, HIGH_COLOR2)
    mask = bin_img1 + bin_img2 # 必要ならマスクを足し合わせる
    masked_img = cv2.bitwise_and(img_blur, img_blur, mask= mask) # 元画像から特定の色を抽出
    
    out_img = masked_img
    num_labels, label_img, stats, centroids = cv2.connectedComponentsWithStats(mask) # 連結成分でラベリングする
    # 背景のラベルを削除
    num_labels = num_labels - 1
    stats = np.delete(stats, 0, 0)
    centroids = np.delete(centroids, 0, 0)
    
    # すべてのラベルを表示
    for index in range(num_labels):
        x = stats[index][0]
        y = stats[index][1]
        w = stats[index][2]
        h = stats[index][3]
        s = stats[index][4]
        mx = int(centroids[index][0]) # 重心のX座標
        my = int(centroids[index][1]) # 重心のY座標
        cv2.rectangle(out_img, (x, y), (x+w, y+h), (255, 0, 255)) # 各ラベルを四角で囲む
        cv2.putText(out_img, "%d,%d"%(mx, my), (x-15, y+h+15), cv2.FONT_HERSHEY_PLAIN, 1, (255, 255, 0)) # 重心を表示
        cv2.putText(out_img, "%d"%(s), (x, y+h+30), cv2.FONT_HERSHEY_PLAIN, 1, (255, 255, 0)) # 面積を表示
        
    cv2.imwrite("out_img.jpg", out_img) # 書き出す
    
    
if __name__ == '__main__':
    labelingAll("test.jpg") # ファイル名を入力
``` 

</details>

## 任意の物体を取り出す
さて，ラベリングができたので最後に目的の物体を取り出します．これにも様々な手法がありますが，今回は最も面積の大きいものを求める物体とすることにします．これは**周囲に検出したい物体と同じ色のものがない（あっても相対的に小さい）ことを前提**にしてます．  

::: note warn
同じ色のものの中で物体を検出したい場合は物体の形から検出する方法などを試してみてください（環境条件によってはきれいに色を抽出できず，求める形を取り出せない場合があります）．
:::  
最大のラベルが欲しいのでNumPyの`argmax`関数で走査して最大面積を持つラベルのインデックスを求めます．  
`max_index = np.argmax(stats[:, 4])`  
完成したコードが以下になります．
```python:detect_target.py
import cv2
import numpy as np

# 赤色は２つの領域にまたがります！！
# np.array([色彩, 彩度, 明度])
# 各値は適宜設定する！！
LOW_COLOR1 = np.array([0, 50, 50]) # 各最小値を指定
HIGH_COLOR1 = np.array([6, 255, 255]) # 各最大値を指定
LOW_COLOR2 = np.array([174, 50, 50])
HIGH_COLOR2 = np.array([180, 255, 255])

def detect_target(img_name):
    img = cv2.imread(img_name) # 画像を読み込む

    img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV) # RGB => YUV(YCbCr)
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)) # claheオブジェクトを生成
    img_yuv[:,:,0] = clahe.apply(img_yuv[:,:,0]) # 輝度にのみヒストグラム平坦化
    img = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR) # YUV => RGB
    
    img_blur = cv2.blur(img, (15, 15)) # 平滑化フィルタを適用
     
    hsv = cv2.cvtColor(img_blur, cv2.COLOR_BGR2HSV) # BGRからHSVに変換
    
    bin_img1 = cv2.inRange(hsv, LOW_COLOR1, HIGH_COLOR1) # マスクを作成
    bin_img2 = cv2.inRange(hsv, LOW_COLOR2, HIGH_COLOR2)
    mask = bin_img1 + bin_img2 # 必要ならマスクを足し合わせる
    masked_img = cv2.bitwise_and(img_blur, img_blur, mask= mask) # 元画像から特定の色を抽出
    
    out_img = masked_img
    num_labels, label_img, stats, centroids = cv2.connectedComponentsWithStats(mask) # 連結成分でラベリングする
    # 背景のラベルを削除
    num_labels = num_labels - 1
    stats = np.delete(stats, 0, 0)
    centroids = np.delete(centroids, 0, 0)

    if num_labels >= 1: # ラベルの有無で場合分け
        max_index = np.argmax(stats[:, 4]) # 最大面積のインデックスを取り出す
        # 以下最大面積のラベルについて考える
        x = stats[max_index][0]
        y = stats[max_index][1]
        w = stats[max_index][2]
        h = stats[max_index][3]
        s = stats[max_index][4]
        mx = int(centroids[max_index][0]) # 重心のX座標
        my = int(centroids[max_index][1]) # 重心のY座標
        cv2.rectangle(out_img, (x, y), (x+w, y+h), (255, 0, 255)) # ラベルを四角で囲む
        cv2.putText(out_img, "%d,%d"%(mx, my), (x-15, y+h+15), cv2.FONT_HERSHEY_PLAIN, 1, (255, 255, 0)) # 重心を表示
        cv2.putText(out_img, "%d"%(s), (x, y+h+30), cv2.FONT_HERSHEY_PLAIN, 1, (255, 255, 0)) # 面積を表示
    else:
        print("目標物が見当たりません！！")
                    
    cv2.imwrite("out_img.jpg", out_img) # 書き出す
    
    
if __name__ == '__main__':
    detect_target("test.jpg") # ファイル名を入力
```
出力結果は，  
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3298420/511df4bd-00ed-62c2-0691-20db54ed7f3f.jpeg" alt="最大面積のラベルを取り出した画像" width=300px>  
目的の物体を検出できましたね♪  
これで完成です！！お疲れ様でした～  

# ５．まとめ
今回は色情報から特定の物質を検出するプログラムを作成しました．画像を用いて物体を検出しようとすると周囲の環境の影響を大きく受けます．どのような環境下でも画像処理を行えるようにすることがプログラマーの目標ですがなかなか難しいものです... 
OpenCVを用いると複雑な画像処理でも数行で実現できてしまいます！ぜひ一度試してみて下さい✨