Pythonによる画像背景除去方法(openCV)
はじめに
画像の背景だけを除去しメイン(前景)を抽出する(トリミング)技術は以前からあり需要があると思われます。 動画においては動くものだけを抽出したり、 近年ではリモート等でWebカメラを使用する際のプライバシー用途でも背景除去は使用されています。 今回はPythonを使用しカメラ・動画ではなく画像の背景を除去します。 とりわけPythonを使用した背景除去には以下の二つが挙げられます。
- openCVライブラリを使用した背景除去
- DeepLearningのsegmentation技術を使用した背景除去
今回は前者の手法で背景除去を行い、どのような画像で適用可能なのかを確かめます。 次回は後者の手法を行います。
バージョン
- Python 3.9.7
- opencv-python 4.5.5
openCVによる背景除去の流れ
- 画像の二値化を行う
- 白色の領域の輪郭を抽出し、前景候補の中から本命となるものを選択する
- マスク処理を行い前景領域を抽出
画像の二値化
前景の欲しい部分といらない背景に分けるために二値化を行います。 二値化を行う場合はグレースケールに変更後、大津の二値化を行うのが一般的です。 また二値化を行う前に必要に応じてヒストグラム平坦化処理や、その他二値化のための特徴抽出処理が行われることがあります。
def get_bgmask(img):
img_subred = img.copy()
gray = cv2.cvtColor(img_subred, cv2.COLOR_BGR2GRAY)
gray = cv2.equalizeHist(gray)
_, thrsh = cv2.threshold(gray, 0, 255, cv2.THRESH_OTSU)
thrsh = cv2.bitwise_not(thrsh)
return thrsh
二値化を行うだけで、前景を取得できてしまう場合もあります。
しかし大抵は以下のように前景に背景と似たような部分がある場合以下のように失敗してしまします。 この画像には内部の4つのうち2つの星が抜けてしまっています。
このような場合は輪郭の抽出処理を行います。
輪郭抽出と前景選択
輪郭はcv2.findContours関数があります。 ノイズなどの影響により余計な部分も抽出されてしまうことがしばしばあります。 よってある程度条件を絞って候補の中から望む部分の領域を選択する必要があります。 今回は領域の面積によって候補の領域を絞ります。 候補は画像の5%-80%の面積を占めていると仮定して絞り込み、その中の面積の最大値のものを前景とします。 以下の関数で返されるのは、領域のmaskになります。
def get_bgmask(img, r=(0.05, 0.80)):
height, width = img.shape[:2]
base_area = height * width
img_subred = img.copy()
gray = cv2.cvtColor(img_subred, cv2.COLOR_BGR2GRAY)
gray = cv2.equalizeHist(gray)
_,thrsh = cv2.threshold(gray, 0, 255, cv2.THRESH_OTSU)
contours, hierarchy = cv2.findContours(thrsh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
mask = np.zeros(thrsh.shape, dtype=np.uint8)
for cont in contours:
cont_area = cv2.contourArea(cont)
print(cont_area, base_area)
if cont_area >= base_area * r[0] and cont_area <= base_area * r[1]:
cv2.fillConvexPoly(mask, cont, color=255)
return mask
これで星の抜けがなく抽出することができました。
前景がいくつかの領域に分かれていたり連結でない場合や穴が開いている場合はさらに処理を行う必要があります。 これらの場合はcv2.RETR_EXTERNAL以外のhierarchyを利用します(ここでは割愛)。
前景領域を抽出
そのままmask処理を行うだけでは透過png画像とはならないので、透過処理を行う必要があります。 mask領域を使用して、画像の前景のみを取り出します。 maskの白色部分を取り出したいので黒色部分の透過率を100%とします。 最後にBGRAのpng画像として取り出します。
img = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
mask = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGRA)
img[:, :, 3] = np.where(np.all(mask == [0, 0, 0, 255], axis=-1), 0, 255)
cv2.imwrite('./result.png', img)
この手法における困難な点
この手法はどれだけうまく二値化できるかがネックとなっており、 少しでも複雑な背景だったり前景と背景が似てる場合などはうまく抽出できないことがあります。
このようにグラデーションをかけただけで二値化が思うとおりに働いていないので、 輪郭もうまく抽出することができていません。 うまく二値化してくれるには背景と前景の輪郭部分の配色が分離している必要があります。 以下のグレイスケールをヒストグラム化した画像です。 一番左の画像は抽出に成功した画像で、真ん中と一番右の画像はそれぞれ失敗した画像の背景のみの画像と背景と前景を合わせた画像です。 ヒストグラムから分かる通り、前景と背景のヒストグラムが重なっており分離していません。
エッジ検出や他の二値化手法から背景除去を行う(追記)
cannyエッジを検出する方法や他の二値化手法を使う手もあります。
ただ背景のみの画像を所持している場合は、背景差分により前景を容易に取得可能です。
背景のみの画像がある場合(背景差分)
背景のみの画像がある場合、背景のみの画像とXORを行うことにより容易に前景を抽出することが可能です。
def get_bgmasksub(img, bg):
img_xor = cv2.bitwise_xor(img, bg)
gray = cv2.cvtColor(img_xor, cv2.COLOR_BGR2GRAY)
_,thrsh = cv2.threshold(gray, 0, 255, cv2.THRESH_OTSU)
return thrsh
結果は以下のようになります。
ノイズが出てしまう場合はmaskに対してblurを行うかオープニング / クロージングを行うと解決するかもしれません。 固定カメラ等を使用した動画では普通はほしい領域は動いているケースが多いため、背景差分を使用することができます。 実際openCVにはBackgroundSubtractor関数があり、この関数を使用することで動画において比較的容易に領域分割を行うことができます。
まとめ
本手法では、背景と前景が配色の面で分離して(つまり二値化しやすい二峰性のヒストグラム)いればそれなりの精度が出ると思われます。 色の複雑性が増せば増すほど、画像一つ一つに二値化のための特徴抽出の対応が強いられ、大量の画像に対してトリミングを行うことは困難です。 ですが背景を固定しその差分をとる手法を用いる方は高精度で前景を抽出することが可能です。 次回はDeepLearningを使用したsegmentationの手法を使用して背景と前景領域の分割を行います。