numpyで画像の二重の重ね合わせ処理をする　～透視メガネを作る～

# はじめに
透視メガネは男のロマン。ドラえもんを思い浮かべるかTo LOVEるを連想するかインチキ通販の苦い思い出を蘇らせるかは人によって異なる。
これをPythonで実装してみよう。といってもディープラーニングを駆使して着衣の奥にある見えない裸体をAIに生成させるわけではなく、あらかじめ脱衣画像を用意しておく必要があるわけだが。[空間を切り裂くプログラム](https://qiita.com/mo256man/items/122228f3ab7101f24059)とほぼ同内容だ。

多くのテレビゲームには登場キャラクターの立ち絵がある。恋愛要素のあるアドベンチャーゲームなどでは制服や私服や水着で同一のポーズの画像が用意されていることも少なくない。空間を切り裂くプログラムではそういうのを使ってアウトプットを作りたかったのだが、残念なことにそのようなゲームを持っていなかった。当時は。
しかし今は違う。今の私はかわいい子をひん剥くことができる。
かわいい子の名前はリンク。そう、ブレスオブザワイルドの。去年の12月にようやくSwitchを買ったのよね。

|着衣　linkA.jpg|脱衣　linkB.jpg|
|:-:|:-:|
|![linkA.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/3033f6a1-f456-1431-e52e-ea3e3dba6ed2.jpeg)|![linkB.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/12877537-7ea4-e59c-bc52-c3ef5aafeca6.jpeg)|


# ソース
透視を実装するにはスプライトの処理を2回おこなうのとは別の、二重の重ね合わせが必要だ。
それを実装するにあたりこれまで[スプライトの勉強](https://qiita.com/mo256man/items/f7524dd34718a01fb3df)でこだわってきたマスク処理でなく`numpy.where()`を使うことにした。これならば複数の透明色を設定できる。
ちなみに特撮ではブルーとグリーンのいる戦隊ヒーローは以下のような苦労をしているそうな。

https://togetter.com/li/1729912

透視メガネの画像は自分で作った。アニメアニメした画像で同様のことをする際は別の色にしたほうがよいが、その際は注意が必要だ（後述）。

|glass.png|
|:-:|
|![glass.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/e6390728-aa44-2f02-7f84-4a66c50133a5.png)|

```Python:sukesuke.py
import cv2
import numpy as np

def put_sprite(event, x, y, flags, param):
    if event == cv2.EVENT_MOUSEMOVE:
        img = sukesuke(imgA, imgB, glass, (x, y), home=(130,130))
        cv2.imshow(winname, img)

def sukesuke(img1, img2, glass, pos, home=(0,0)):
    result = img1.copy()
    imgH, imgW = result.shape[:2]
    gH, gW = glass.shape[:2]
    x, y = pos
    xc, yc = home
    x, y = x-xc, y-yc
    x1, y1 = max(x, 0), max(y, 0)
    x2, y2 = min(x+gW, imgW), min(y+gH, imgH)

    if not ((-gW < x < imgW) and (-gH < y < imgH)):
        return img1

    outer_color = (255,255,255)
    inner_color = (0,0,0)
    img1_roi = img1[y1:y2, x1:x2]
    img2_roi = img2[y1:y2, x1:x2]
    glass_roi = glass[y1-y:y2-y, x1-x:x2-x]
    temp1 = np.where(glass_roi==outer_color, img1_roi, glass_roi)
    temp2 = np.where(glass_roi==inner_color, img2_roi, temp1)
    result[y1:y2, x1:x2] = temp2
    return result

imgA = cv2.imread("linkA.jpg")
imgB = cv2.imread("linkB.jpg")
glass = cv2.imread("glass.png")
winname = "sukesuke"
cv2.namedWindow(winname)
cv2.setMouseCallback(winname, put_sprite)
cv2.imshow(winname, imgA)

while True:
    if cv2.waitKey(1) & 0xFF == 27:  # esc
        break
cv2.destroyAllWindows()
```

これにより以下のようにじっくりねっとりと透視メガネを楽しむことができる。

|結果|
|:-:|
|![link.gif](https://raw.githubusercontent.com/mo256man/komono/main/link.gif)|

# 疑問
ところが、透視メガネの色を変更させると期待通りに動かなくなってしまうことがある。もちろん最新の`numpy==1.21.1`での話だ。
これは一体どういうことだろう（まさか`outer_color`や`inner_color`の変更を間違えているということはないと信じたい）。


```Python:sukesuke.pyの一部を変更

def sukesuke(img1, img2, glass, pos, home=(0,0)):
    # 中略
    outer_color = (255, 0, 0)  # 必要に応じて色指定を変更する
    inner_color = (0, 0, 255)  # 必要に応じて色指定を変更する
    # 中略
    temp1 = np.where(glass_roi==outer_color, img1_roi, glass_roi)
    cv2.imshow("temp1", temp1)  # 追加する
    temp2 = np.where(glass_roi==inner_color, img2_roi, temp1)
    cv2.imshow("tmp2", temp2)  # 追加する
    result[y1:y2, x1:x2] = temp2
    return result
```

|glass|tmp1（途中の姿）|tmp2（最終の姿）|
|:-:|:-:|:-:|
|![glass.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/e6390728-aa44-2f02-7f84-4a66c50133a5.png)<br>背景＝(255,255,255)<br>レンズ＝(0,0,0)<br>縁＝(208,99,244)|![link11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/bb178518-1517-874c-8543-c6ef4db2f8fc.png)<br>背景：正しく着衣<br>レンズ：正しい色<br>縁：正しい色|![link12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/98fef5c1-2c9c-55fc-00b0-ea8d0739d976.png)<br>背景：正しく着衣<br>レンズ：正しく裸<br>縁：正しい色|
|![link30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/d492ed2d-e025-d266-0ac2-d17a8f0c2fd7.png)<br>背景＝(255,255,255)<br>レンズ＝(0,0,0)<br>縁＝(0,255,0)|![link31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/23db7b3b-9483-bdd2-3c17-1e7318f35f30.png)<br>背景：正しく着衣<br>レンズ：正しい色<br>縁：合成|![link32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/008429d6-52ab-929e-de26-5643a8dd7fcb.png)<br>背景：正しく着衣<br>レンズ：正しく裸<br>縁：合成|
|![link20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/aff5d2fd-3669-11ac-a67f-fd05620d6e1d.png)<br>背景=(255,0,0)<br>レンズ＝(0,0,255)<br>縁＝(255,255,255)|![link21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/432c087a-1f1f-6141-6ab0-45768962c47c.png)<br>背景：正しく着衣<br>レンズ：合成<br>縁：合成|![link22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/8e14b0c9-fb6e-4b89-8588-3b79b1345476.png)<br>背景：合成<br>レンズ：正しく裸<br>縁：合成|


リンクきゅんの股間ばかり注視してしまいたいへん恐縮だが、この辺りがもっとも違いが分かりやすいので勘弁してほしい。
**`numpy.where(condition, x, y)`**は`condition`によって`x`もしくは`y`のどちらかを返すという関数のはずなのだが、なぜか`x`と`y`の演算が発生してしまっているようだ。
本件について知見のある方はぜひ教えてください。

https://numpy.org/doc/stable/reference/generated/numpy.where.html

# 終わりに
世間一般から4年以上遅れているが、女装リンク、いい…。

# 追記：疑問解消
## `numpy.where()`の仕様
どうやら`numpy.where()`は多次元配列では個々の要素について評価がおこなわれるようだ。
といっても具体的にどのような評価をしているのかはよくわからなかった。
`numpy.where()`でも期待通りに動くこともあるし。

```python
temp1 = np.where(glass_roi==outer_color, img1_roi, glass_roi)
temp2 = np.where(glass_roi==inner_color, img2_roi, temp1)
```


||glass|着衣画像|temp1|temp2|
|:-:|:-:|:-:|:-:|:-:|
|`np.where()`|![glass.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/dc0ca2ff-6f0c-26d3-2cdb-7661b12964d9.png)|![link_bust.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/5b2b7cfe-79e2-0229-044b-584a6bb4e818.png)|![temp11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/a7af0119-edf1-5119-5d40-8aeaaa247738.png)|![temp12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/ef6017fd-226b-5f2a-ce75-5a714471810a.png)|
|座標<br>`(50,50)`の<br>BGR値|レンズの色<br>`(0,0,255)`|マスクの色<br>`(188,203,135)`|なぜか<br>`(0,203,255)`|肌色<br>`(131,190,239)`|
|備考|outer_color = <br>`(255,0,0)`||||

## 対策　`numpy.all()`を使う

`(height, width, 3channel)`の3次元配列で最後のBGR要素が等しいかどうかを評価するには`numpy.all()`を使う。

配列の指定した軸がすべてTrueであればTrueを返す
　　**numpy.all(a, axis=None, out=None, keepdims=False)**
配列の指定した軸の少なくとも一つがTrueであればTrueを返す
　　**numpy.any(a, axis=None, out=None, keepdims=False)**

 - *a*　　　　　配列に相当するもの。
 - *axis*　　　軸。タプルで複数指定可。省略すると全次元。
 - *out*　　　　戻り値を出力する変数。省略可能でデフォ値は`None`。普通に`result = numpy.all()`と書けばこんなの不要だと思うんだけど。
 - *keepdims*　戻り値の次元を元の配列と同じにするかどうか。省略可能でデフォ値は`False`。

公式なドキュメントはこちら。

https://numpy.org/doc/stable/reference/generated/numpy.all.html

`numpy.where()`ほど知名度が高くない（？）この`numpy.all()`、いくつかのウェブサイトを調べたが教科書通りの使い方しか紹介されていなかった。しかし、これをOpenCVの画像に使うといろいろ面白いことができる。

### 画像の各ピクセルの色が等しいかどうかを評価する
カラー画像は`(height, width, 3channel)`の3次元で表現されるから`axis=2`を指定する。そしてここが肝心なのだが、評価対象は「画像の各ピクセルの色が指定したBGR値に等しいかどうか」とする。つまり、

```Python:
cond1 = np.all(glass_roi==outer_color, axis=2)
```

と書けばよい。
戻り値は**各要素が`True`もしくは`False`の値を持つ(height, width)の2次元配列**だ。

### 評価結果を`numpy.where()`にかける

これを元に`numpy.where()`を使ってピクセルごとに画像Aの色と画像Bの色を振り分けたいところだが、残念ながら2次元配列をもとに3次元配列を振り分けることはできない。
そこで使うのが`keepdims`オプション。これを有効にすることで次元が保たれる。numpyだから`numpy.resize()`を使ってもよいが、このオプションはありがたい。
戻り値は**各要素が`True`もしくは`False`の値を持つ(height, width, 1)の3次元配列**。これで`numpy.where()`を使うことができるようになる。

```Python:
cond1 = np.all(glass_roi==outer_color, axis=2, keepdims=True)
temp1 = np.where(cond1, img1_roi, glass_roi)

cond2 = np.all(glass_roi==inner_color, axis=2, keepdims=True)
temp2 = np.where(cond2, img2_roi, temp1)
```

||glass|着衣画像|temp1|temp2|
|:-:|:-:|:-:|:-:|:-:|
|`np.all()`<br>+<br>`np.where()`|![glass.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/dc0ca2ff-6f0c-26d3-2cdb-7661b12964d9.png)|![link_bust.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/5b2b7cfe-79e2-0229-044b-584a6bb4e818.png)|![temp21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/9a0132d0-275f-8dfb-6b06-29d16f196c7a.png)|![temp22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/641993b9-14d2-546a-78c2-5db185b1cffe.png)|
|備考|outer_color = <br>`(255,0,0)`||期待通り|期待通り|

## ソース改
変更部分は多くないがコピペしてすぐに遊べるよう全ソースを示しておく。

```Python:sukesuke.py
import cv2
import numpy as np

def put_sprite(event, x, y, flags, param):
    if event == cv2.EVENT_MOUSEMOVE:
        img = sukesuke(imgA, imgB, glass, (x, y), home=(130,130))
        cv2.imshow(winname, img)

def sukesuke(img1, img2, glass, pos, home=(0,0)):
    result = img1.copy()
    imgH, imgW = result.shape[:2]
    gH, gW = glass.shape[:2]
    x, y = pos
    xc, yc = home
    x, y = x-xc, y-yc
    x1, y1 = max(x, 0), max(y, 0)
    x2, y2 = min(x+gW, imgW), min(y+gH, imgH)

    if not ((-gW < x < imgW) and (-gH < y < imgH)):
        return img1

    # ここも小改造しています　自作メガネ画像の仕様に準じて色の自動取得をする
    outer_color = glass[0][0]   # 画像の左上、背景部の色
    inner_color = glass[50][50] # 座標(50,50)、レンズ部の色
    
    img1_roi = img1[y1:y2, x1:x2]
    img2_roi = img2[y1:y2, x1:x2]
    glass_roi = glass[y1-y:y2-y, x1-x:x2-x]

    # 主な変更部分はここ
    cond1 = np.all(glass_roi==outer_color, axis=2, keepdims=True)
    temp1 = np.where(cond1, img1_roi, glass_roi)
    cond2 = np.all(glass_roi==inner_color, axis=2, keepdims=True)
    temp2 = np.where(cond2, img2_roi, temp1)
    result[y1:y2, x1:x2] = temp2
    return result

imgA = cv2.imread("linkA.jpg")
imgB = cv2.imread("linkB.jpg")
glass = cv2.imread("glass.png")
winname = "sukesuke"
cv2.namedWindow(winname)
cv2.setMouseCallback(winname, put_sprite)
cv2.imshow(winname, imgA)

while True:
    if cv2.waitKey(1) & 0xFF == 27:  # esc
        break
cv2.destroyAllWindows()
```

## 終わりに改
`numpy.where()`についてはやや消化不良だが目的が達成できたので良しとしよう。
夏休みが終わって出勤したら突然適切な方法（`numpy.all()`は知らなかったので正確には適切な検索ワード）を思いついた。
環境が変わると思考も変わって解法にたどり着くことができるようになるという実例でもある。