LoginSignup
38
32

More than 3 years have passed since last update.

OpenCVで透過画像を扱う ~スプライトを舞わせる~

Last updated at Posted at 2020-06-27

はじめに

OpenCVで透過png画像を扱い、往年のホビーパソコンのスプライト的なことをやってみよう。
本格的にスプライトを使ったゲームを作りたい人はpygameを使うとよい。私はOpenCVの勉強をしたいだけだ。

基本 RGBA要素について

ここではいらすとやの「決めポーズを取る戦隊もののキャラクターたち(集合)」(を縮小したもの)を使う。背景は透過処理されている。

sentai.png
sentai.png

RGBA画像を取り込むにはcv2.imread()flags = cv2.IMREAD_UNCHANGEDと指定する。
実際はそんな呪文を覚える必要はなく、2番目の引数として-1を指定すればよい。引数を1にするもしくは省略するとRGBの3チャンネル画像として取り込まれる。

import cv2
filename = "sentai.png"
img4 = cv2.imread(filename, -1)
img3 = cv2.imread(filename)
print (img4.shape)  # 結果:(248, 300, 4)
print (img3.shape)  # 結果:(248, 300, 3)

なお、この画像をcv2.imshow()で表示したとき背景が黒になる。お絵かきソフトで透明キャンバスの上に絵を描くと、透明の部分は色要素がないために黒として扱われるわけだ。RGB成分がすべてゼロ=(0,0,0)=黒ということ。

RGBA各要素を取り出す

何度も書くがOpenCV画像はnumpy.ndarray形式でデータ格納されているので、トリミングだけでなく各色要素を取り出すのもスライスでできる。

import cv2
filename = "sentai.png"
img4 = cv2.imread(filename, -1)
b = img4[:, :, 0]
g = img4[:, :, 1]
r = img4[:, :, 2]
a = img4[:, :, 3]

cv2.imshow("img4", img4)
cv2.imshow("b", b)
cv2.imshow("g", g)
cv2.imshow("r", r)
cv2.imshow("a", a)
cv2.waitKey(0)
cv2.destroyAllWindows()
元画像 A要素
sentai.png imgA.png
B要素 G要素 R要素
imgB.png imgG.png imgR.png

各要素はndim=2すなわち(height, width)のシェイプとなっている。だから各要素を画像として表示させるとグレースケールになる。
アカレンジャーはたとえば(B,G,R)=(21,11,213)なので赤の輝度がかなり高い一方で青と緑の輝度は低い。
その一方でキレンジャーは(B,G,R)=(0,171,247)なので赤の輝度はアカレンジャー以上に高く、また緑要素もそれなりに高いことがわかる。
また、アルファ値は0が透明で255が不透明。透明度というより不透明度と覚えたほうがよさそうだ。

では各色要素を各色で表示するにはどうしたらよいかというと、他色成分を0としたRGB画像をあらためて作ってやればよいのだ。

方法その1
import cv2
import numpy as np
filename = "sentai.png"
img3 = cv2.imread(filename)

b = img3[:, :, 0]
g = img3[:, :, 1]
r = img3[:, :, 2]
z = np.full(img3.shape[:2], 0, np.uint8)
imgB = cv2.merge((b,z,z))
imgG = cv2.merge((z,g,z))
imgR = cv2.merge((z,z,r))

cv2.imshow("imgB", imgB)
cv2.imshow("imgG", imgG)
cv2.imshow("imgR", imgR)
cv2.waitKey(0)
cv2.destroyAllWindows()

元のRGB画像で不必要な色の輝度を0にするというやり方もある。

方法その2
import cv2
import numpy as np
filename = "sentai.png"
img3 = cv2.imread(filename)

imgB = img3.copy()
imgB[:, :, (1,2)] = 0  # 3ch(BGR)の1番目(G)と2番目(R)を0にする
imgG = img3.copy()
imgG[:, :, (0,2)] = 0  # 3ch(BGR)の0番目(B)と2番目(R)を0にする
imgR = img3.copy()
imgR[:, :, (0,1)] = 0  # 3ch(BGR)の0番目(B)と1番目(G)を0にする

cv2.imshow("imgB", imgB)
cv2.imshow("imgG", imgG)
cv2.imshow("imgR", imgR)
cv2.waitKey(0)
cv2.destroyAllWindows()
B要素を青で G要素を緑で R要素を赤で
imgB.png imgG.png imgR.png

画像を合成する

ここからが本番。
背景画像は「宇宙のイラスト(背景素材)」とする。jpeg画像で、アルファ値は持たない。
背景の上に重ねる画像は「宇宙飛行士のイラスト」(を縮小したもの)。

space.jpg
space.jpg
uchuhikoushi.png
uchuhikoushi.png

事前準備

RGBA画像からRGB画像とマスク画像を作る。先ほど「A要素」として挙げたのは1チャンネルなので背景画像と合成することはできない。B要素を青くR要素を赤くしたのと似た方法で、RGB3チャンネルのマスク画像を作る。

import cv2

filename = "uchuhikoushi.png"
img4 = cv2.imread(filename, -1)

img3 = img4[:, :, :3]  # RGBAのうち最初の3個すなわちRGB
mask1 = img4[:, :, 3]  # RGBAのうち0から数えて3番目すなわちA
mask3 = cv2.merge((mask1, mask1, mask1)) # 3チャンネルのRGB画像とする
元画像(RGBA) RGB画像 マスク画像
uchuhikoushi.png uchuhikoushi_3.png mask3.png

また、背景から前景と同サイズの画像を切り出しておこう。

方法1 透明色を設定する

前景画像の中に「この色は主たる画像の一部ではなく、背景として使われているだけだ」という色がある場合、numpy.where()を使って透明色を設定できる。クロマキーのようなものだ。
最小限の要素のみ書くとこうなる。

# backとfrontは同じシェイプである必要がある
transparence = (0,0,0)
result = np.where(front==transparence, back, front)
back front result
back.jpg uchuhikoushi_3.png result.jpg

容易は容易だが、たとえばこのいらすとやの画像ならば、宇宙飛行士の黒髪に(0,0,0)が使われていないことを事前に確認しておく必要がある。失敗すると透明になったガチャピンのような事態になってしまう。

方法2 マスク処理をする

他サイトを見れば答えはすぐに出てくるが、勉強のために試行錯誤してみよう。
論理演算における恒等式
  x and 1 = x
  x and 0 = 0
  x or 1 = 1
  x or 0 = x
を、0と1のブール値だけでなく任意の値に対しておこなう。輝度は8ビットで表すから、乱暴に書くと
  x(任意の色) and 255(白) = x(任意の色)
  x(任意の色) and 0(黒) = 0(黒)
  x(任意の色) or 255(白) = 255(白)
  x(任意の色) or 0(黒) = x(任意の色)
ということ。
下の表は手作業で作ったので、間違っているところがあったらごめんなさい。

No back 演算 mask tmp
1 back.jpg OR mask3.png  →  result1.jpg
2 back.jpg AND mask3.png  →  result2.jpg
3 back.jpg OR mask3_inv.png  →  result3.jpg
4 back.jpg AND mask3_inv.png  →  result4.jpg

この段階で使えそうなのは1番と4番だ。それらに前景画像を合成していこう。

No tmp 演算 front result 評価
1-1 result1.jpg OR uchuhikoushi_3.png result1.jpg ×
1-2 result1.jpg AND uchuhikoushi_3.png uchuhikoushi_3.png ×
1-3 result1.jpg OR front3_white.png result5.jpg ×
1-4 result1.jpg AND front3_white.png result.jpg
4-1 result4.jpg OR uchuhikoushi_3.png result.jpg
4-2 result4.jpg AND uchuhikoushi_3.png result6.jpg ×
4-3 result4.jpg OR front3_white.png front3_white.png ×
4-4 result4.jpg AND front3_white.png result4.jpg ×

ということで、正解は、1-4および4-1でした。
事前に「背景が黒の前景画像」と「背景が黒で前景が白のマスク画像」を用意しておいたが、それだけでは合成はできなかった。1-4では「背景が白の前景画像」、4-1では「背景が白で前景が黒のマスク画像」が必要だった。まこと人生はままならぬものよ。

画像外への描写への対応

スプライトを名乗るならば背景画像の範囲外にも描写できなければ話にならない。
という解説を書くつもりだったが、すでに前回の記事「OpenCVで日本語フォントを描写する を関数化する を汎用的にする」で実装してしまったので説明は略。

方法3 PILを使う

OpenCVの勉強とはいえ、PILに触れないわけにはいかない。

画像に画像を貼り付けする Image.paste(im, box=None, mask=None)

  • im 貼り付けする画像。
  • box 左上座標を(x, y)であらわす。ありがたいことに元画像の範囲外でも可。デフォ値はNoneでこのときは左上となる。4要素のタプルで指定する方法もあるがそちらは省略。
  • mask マスク画像。デフォ値はNone。画像は白黒やグレースケールだけでなくRGBA画像も指定可能。RGBAのときはアルファ値がマスクとして扱われるという親切仕様。

画像に透過画像を合成するにはImage.paste(im, box, im)と1番目と3番目に同じ引数を指定するだけでよい。
さすがPILだ、これまでOpenCVでやってきたことは何だったのかと言いたくなってくる。

実行速度比較

以上、3つの方法を示した。また、前回の記事で、画像全体をPIL処理するのでなく必要な部分のみPILするという方法も習得した。
そこで、次の4つの自作関数で実行速度を見てみよう。

  • putSprite_npwhere 透明色を設定しnp.whereで画像を合成する。背景画像外への対応あり。
  • putSprite_mask マスク画像を設定し合成する。背景画像外への対応あり。
  • putSprite_pil_all PILで合成する。背景画像全体をPILにする。背景画像外への対応は必要ない。
  • putSprite_pil_roi PILで合成する。PILにするのは合成に必要な部分のみ。ROIを設定する際に背景画像外への対応をおこなっている。

実行する内容はこんな感じ。容量削減のためにアニメGIFのフレームを削っています。
ソースは長いので一番下に。
uchuhikoushi.gif

結果

それぞれ10回実行したときの平均値。

putSprite_npwhere : 0.265903830528259 sec
putSprite_mask    : 0.213901996612548 sec
putSprite_pil_all : 0.973412466049193 sec
putSprite_pil_roi : 0.344804096221923 sec

プログラムが最適化されていないとか私のマシンが遅いとかそもそもPythonは遅いとかは置いといて、PILの遅さが際立っている。そしてPILを使うにしても最小限のROIのみでおこなえば速度が大幅に向上することもわかった。前々回の記事でいただいたコメントは本当にありがたい。

マスク処理とnp.where()はPILよりも速い。np.where()は十分に速いが、内部で判定をおこなっているのでマスク処理よりわずかに遅い。マスク処理はマスク画像を用意する必要があるが、画像を上書きしているだけなので処理的にはもっとも軽いのだろう。

注意事項

マスク処理とnp.where()は半透明を持つ画像には使えない。

マスク処理は0や1だから恒等式が成り立つのであって、半透明すなわち0でも1でもない値で計算したら
  x(背景の色) and a(中途半端なマスク) = tmp(変な色)
  tmp(変な色) or y(前景の色) = z(想定外の色)
となってしまうのは当然だ。

np.where()は工夫次第では半透明に対応できるかと思っていろいろ試してみたが、少なくとも私にはできなかった。

次回予告

次は半透明と回転への対応に挑戦したいと思います。

ソース

文字列をPythonコマンドとして使う関数eval()はこの記事をアップする直前に知った。
参考:Python - 関数を文字列から動的に呼び出す

import cv2
import numpy as np
from PIL import Image
import time
import math

# numpy.whereで合成する
#  他関数との関係上RGBA画像を取り込んでいるが
#  使うのはRGB要素のみでアルファ値は使っていない。
#  透明色(0,0,0)を決め打ちしているが良いやり方ではない。
def putSprite_npwhere(back, front4, pos):
    x, y = pos
    fh, fw = front4.shape[:2]
    bh, bw = back.shape[:2]
    x1, y1 = max(x, 0), max(y, 0)
    x2, y2 = min(x+fw, bw), min(y+fh, bh)
    if not ((-fw < x < bw) and (-fh < y < bh)) :
        return back
    front3 = front4[:, :, :3]
    front_roi = front3[y1-y:y2-y, x1-x:x2-x]
    roi = back[y1:y2, x1:x2]
    tmp = np.where(front_roi==(0,0,0), roi, front_roi)
    back[y1:y2, x1:x2] = tmp
    return back

# マスク処理する
#  マスク画像はRGBA画像から都度関数内で作成する。
#  あらかじめマスク画像を作っておいたほうが速いが、
#  こちらのほうが使いやすい。と思う。
def putSprite_mask(back, front4, pos):
    x, y = pos
    fh, fw = front4.shape[:2]
    bh, bw = back.shape[:2]
    x1, y1 = max(x, 0), max(y, 0)
    x2, y2 = min(x+fw, bw), min(y+fh, bh)
    if not ((-fw < x < bw) and (-fh < y < bh)) :
        return back
    front3 = front4[:, :, :3]
    mask1 = front4[:, :, 3]
    mask3 = 255 - cv2.merge((mask1, mask1, mask1))
    mask_roi = mask3[y1-y:y2-y, x1-x:x2-x]
    front_roi = front3[y1-y:y2-y, x1-x:x2-x]
    roi = back[y1:y2, x1:x2]
    tmp = cv2.bitwise_and(roi, mask_roi)
    tmp = cv2.bitwise_or(tmp, front_roi)
    back[y1:y2, x1:x2] = tmp
    return back

# PILで合成する 背景画像全体
def putSprite_pil_all(back, front4, pos):
    back_pil = Image.fromarray(back)
    front_pil = Image.fromarray(front4)
    back_pil.paste(front_pil, pos, front_pil)
    return np.array(back_pil, dtype = np.uint8)

# PILで合成する 背景画像内にある部分のみ
def putSprite_pil_roi(back, front4, pos):
    x, y = pos
    fh, fw = front4.shape[:2]
    bh, bw = back.shape[:2]
    x1, y1 = max(x, 0), max(y, 0)
    x2, y2 = min(x+fw, bw), min(y+fh, bh)
    if not ((-fw < x < bw) and (-fh < y < bh)) :
        return back
    back_roi_pil = Image.fromarray(back[y1:y2, x1:x2])
    front_pil = Image.fromarray(front4[y1-y:y2-y, x1-x:x2-x])
    back_roi_pil.paste(front_pil, (0,0), front_pil)
    back_roi = np.array(back_roi_pil, dtype = np.uint8)
    back[y1:y2, x1:x2] = back_roi
    return back

def main(func):
    filename_back = "space.jpg"
    filename_front = "uchuhikoushi.png"
    img_back = cv2.imread(filename_back)
    img_front = cv2.imread(filename_front, -1)
    bh, bw = img_back.shape[:2]
    xc, yc = bw*0.5, bh*0.5
    rx, ry = bw*0.3, bh*1.2
    cv2.putText(img_back, func, (20,bh-20), cv2.FONT_HERSHEY_SIMPLEX, 1, (255,255,255))

    ### 時間を計るのはここから
    start_time = time.time()

    for angle in range(-180, 180):
        back = img_back.copy()
        x = int(xc + rx * math.cos(math.radians(angle)))
        y = int(yc + ry * math.sin(math.radians(angle)))
        img = eval(func)(back, img_front, (x,y))

        #ここは必要に応じて有効にしたり無効にしたりする
        #cv2.imshow(func, img)
        #cv2.waitKey(1)

    elasped_time = time.time() - start_time
    ### ここまで

    print (f"{func} : {elasped_time} sec")    
    cv2.destroyAllWindows()

if __name__ == "__main__":
    funcs = ["putSprite_npwhere",
             "putSprite_mask",
             "putSprite_pil_all" ,
             "putSprite_pil_roi" ]
    for func in funcs:
        for i in range(10):
            main(func)
38
32
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
32