投影変換で画像を変形する　～モニタ画面をハックする～

# はじめに
以前の私の記事

 - [OpenCVでスプライトを回転させる](https://qiita.com/mo256man/items/a32ddf5ddbaaf767d319)
 - [OpenCVでスプライトを回転させる #2　～cv2.warpAffine()を使いこなす～](https://qiita.com/mo256man/items/9f4b93dcff0902cb2047)

ではアフィン変換を回転にしか使わなかったので、今回は別の活用方法を示すとともに投影変換でも遊んでみる。

# アフィン変換で平行四辺形を変形する

アフィン行列

```math
\begin{pmatrix}
x' \\
y' \\
1
\end{pmatrix}
=
\begin{pmatrix}
a & b & c \\
d & e & f \\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x \\
y \\
1
\end{pmatrix}
```

は、原点`(0,0)`が`(c,f)`に、単位ベクトル `(1,0)` と `(0,1)` がそれぞれ `(a, d)` と `(b, e)`に変わる、すなわちxy平面がx'y'平面に変わることを意味している。
それは二つのベクトルからなる任意の平行四辺形が別の平行四辺形に変形するということだし、より根源的に考えれば任意の三角形が別の三角形に変形するということでもある。

### アフィン行列を作る　cv.getAffineTransform(src, dst)

 - src　三角形の頂点。`float32`のnumpy配列である必要がある。
 - dst　変更後の3点。srcと同様、`float32`のnumpy配列である必要がある。

高さ300・幅200の画像サイズを定義する3点、すわなち
`pts1 = [(0,0), (200,0), (0,300)]`　を、別の3点
`pts2 = [(50,50), (250,100), (200,300)]`　に変換することを考える。`(x,y)` → `(x',y')` の変化が3個あるのでa,b,c,d,e,f がすべて求まり、アフィン行列が決まる。
自力では計算してないが、`cv.getAffineTransform()`によるとアフィン行列は

```
[[ 1.          0.5        50.        ]
 [ 0.25        0.83333333 50.        ]]
```
となる。ソースは略。これによる画像の変形は以下の通り。
![affine_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/f5b0864a-3d79-3e48-08d8-e90bbf4f7ecd.png)
`(0,0)`が`(50,50)`に、`(200,0)`が`(250,100)`に、`(0,300)`が`(200,300)`になっていることがわかる。
このとき、平行四辺形のルールにより`(200,300)`は`(400,350)`になる。
`(0,0)`から始まっているから頂点は`(200,300)`でなく`(199,299)`だろというツッコミはなしでお願いします。
ちなみに元画像は[世界を救うため最後の戦いに挑むマッチョ2(縦写真)（マッスルプラス）](https://freephotomuscle.com/archives/photo/1035)だ。



# 投影変換
より自由度が高い、任意の四角形を別の四角形に変形する変換を投影変換という。
投影行列は

```math
\begin{pmatrix}
x' \\
y' \\
1
\end{pmatrix}
=
\begin{pmatrix}
a & b & c \\
d & e & f \\
g & h & 1
\end{pmatrix}
\begin{pmatrix}
x \\
y \\
1
\end{pmatrix}
```

というかたちをしている。

### 投影行列を作る　cv.getPerspectiveTransform(src, dst)

 - src　四角形の頂点。`float32`のnumpy配列である必要がある。
 - dst　変更後の4点。srcと同様、`float32`のnumpy配列である必要がある。

アフィン変換の`cv.getAffineTransform()`と同じだな。

### 画像を投影変換する　cv.warpPerspective(src, M, dsize)
こちらもアフィン変換の`cv.warpAffine()`と同じ。

## 実践
高さ300・幅200の画像の4点、すなわち
`pts1 = [(0,0), (0,300), (200,300), (200,0)]`　を、別の4点
`pts2 = [(50,100), (100,400), (300,300), (200,50)]`　に変換することを考える。8元連立方程式なんて自力で解く気もないが、`cv.getPerspectiveTransform()`によると投影行列は

```
[[ 8.33333333e-01  6.94444444e-02  5.00000000e+01]
 [-2.29166667e-01  6.11111111e-01  1.00000000e+02]
 [ 4.16666667e-04 -9.72222222e-04  1.00000000e+00]]
```
となる。そしてその変化を画像で見るとこうなる。各頂点の変換具合がわかるだろう。
![perspective1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/1636d05d-e916-6c3b-f6e6-9c4f46bfc1ee.png)

### ソース
ソースは以下の通り。二次元配列から各列の最大値を求めるのに無名関数`lambda`を使ったり`matplotlib.pyplot`で軸を共有してグラフを並べたりと今の私にはオーバースペックな技を使っている。

```Python:
import cv2
import matplotlib.pyplot as plt
import numpy as np

img1 = cv2.imread("mustle.jpg")
h1, w1 = img1.shape[:2]

pts2 = [(50,100), (100,400), (300,300), (200,50)]
w2 = max(pts2, key = lambda x:x[0])[0]
h2 = max(pts2, key = lambda x:x[1])[1]

pts1 = np.float32([(0,0), (0,h1), (w1,h1), (w1,0)])
pts2 = np.float32(pts2)

M = cv2.getPerspectiveTransform(pts1,pts2)
img2 = cv2.warpPerspective(img1, M, (w2,h2), borderValue=(255,255,255))
print (M)

fig, (ax1, ax2) = plt.subplots(1, 2, sharex=True, sharey=True)
ax1.imshow(img1[:,:,::-1])
ax2.imshow(img2[:,:,::-1])
plt.show()
```

# 応用例
長方形を不等辺四角形にするだけでなく、斜めから撮影して不等辺四角形になってしまった要素（絵画など）を正面から撮影したかのように取り出すことができる。もちろん、取り出したい長方形のサイズ（縦横比）は把握しておく必要がある。

[Shuto選手VSときど選手の決勝ステージの様子 - CAPCOM Pro Tour 2019 アジアプレミアの写真素材（ぱくたそ）](https://www.pakutaso.com/20200331090capcom39.html)で遊んでみよう。

|元画像 sf5.jpg|
|:-:|
|![st5.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/98b8df0f-67a0-4657-917f-c22c0e5eaa78.jpeg)|

|取り出した画像|
|:-:|
|![cut_image.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/e1f63df7-bb4a-c696-ba25-56f5fe2b87a5.jpeg)|

このモニター画像を取り出し、別の画像に置き換えてみる。
画像は[命を懸けてあっちむいてホイするマッチョ（マッスルプラス）](https://freephotomuscle.com/archives/photo/1038)だ。ゲーム画面と同じアスペクト比16:9でトリミングしておく。

|事前に用意しておいた画像　macho.jpg|
|:-:|
|![macho.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/abc9bed9-9904-30d7-67fb-5a6feabb6561.jpeg)|

これを先ほど指定した不等辺四角形に変形する。

|中間画像|
|:-:|
|![front.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/907cdc10-17d2-cadb-fe49-d216c08883f7.jpeg)|

それを元画像に合成すれば、超リアルな対戦格闘ゲームで戦う光景のできあがりだ。

|画面がハックされた画像|
|:-:|
|![hacked_image.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/c3a6d8f4-bffb-0bdf-85ef-494c68051819.jpeg)|

中間画像まではうまく作れているのだが、合成結果だとあちこちに赤いノイズが出てしまっている。
白地の上に合成したり透明色をほかの色に設定したりしても同様だったので合成の悪さではないと思うのだが、原因はよくわからない。

|一連の操作をアニメGIF化|
|:-:|
|![sf5_anim.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/71c15fb2-9a9d-7878-273f-03f32bf9287a.gif)|

### ソース
以前の記事[「OpenCVでマウスイベントを取得する　～GUIな集中線ツールを作る～」](https://qiita.com/mo256man/items/3a9e67cb5b9fe32839f0)ではメインルーチンとコールバック関数の間をグローバル変数でやりとりしていたが、今回は`cv2.setMouseCallback()`の`param`を活用して少しだけエレガントにしてみた。
上のアニメGIFにもあるようにカーソル位置だけでなく四角形の辺も表示させている（最後の1点を決めるときはちゃんと閉じた四角形になっている）し、右クリックによる「一手戻る」も実装しているし、なかなか気の利いたコードになったのではないかと満足している。

```Python:hack_image.py
import numpy as np
import cv2
import random

def draw_quadrangle(event, x, y, flags, param):
    img = param["img"]
    pts = param["pts"]
    pic = param["pic"]
    
    color = (random.randint(0,255), random.randint(0,255), random.randint(0,255))
    img_tmp = img.copy()

    if event == cv2.EVENT_MOUSEMOVE and len(pts) <= 3:
        h, w = img.shape[:2]
        cv2.line(img_tmp, (x,0), (x,h-1), color)
        cv2.line(img_tmp, (0,y), (w-1,y), color)        
        cv2.imshow("image", img_tmp)
        
    if event == cv2.EVENT_LBUTTONDOWN:
        pts.append((x, y))
        if len(pts) == 4:
            h, w = img.shape[:2]
            ph, pw = pic.shape[:2]
            pts1 = np.float32(pts)
            pts2 = np.float32([[0,0],[0,ph],[pw,ph],[pw,0]])

            M1 = cv2.getPerspectiveTransform(pts1,pts2)
            tmp = cv2.warpPerspective(img,M1,(pw,ph))
            #cv2.imwrite("cut_image.jpg", tmp)

            M2 = cv2.getPerspectiveTransform(pts2,pts1)
            transparence = (128,128,128)
            front = cv2.warpPerspective(pic, M2, (w,h), borderValue=transparence)
            img = np.where(front==transparence, img, front)
            #cv2.imwrite("front.jpg", front)
            cv2.imshow("image", img)
            #cv2.imwrite("image.jpg", img)

    if event == cv2.EVENT_RBUTTONDOWN and len(pts)>0:
        del pts[-1]
        
    if 0 < len(pts) <= 3:
        for pos in pts:
            cv2.circle(img_tmp, pos, 5, (0,0,255), -1)

        cv2.line(img_tmp, pts[-1], (x,y), color, 1)
        if len(pts)==3:
            cv2.line(img_tmp, pts[0], (x,y), color, 1)

        isClosed = True if len(pts)==4 else False
        cv2.polylines(img_tmp, [np.array(pts)], isClosed, (0,0,255), 1)
        cv2.imshow("image", img_tmp)

def main():
    img_origin = cv2.imread("sf5.jpg")
    pic = cv2.imread("macho.jpg")
    pts = []
    cv2.imshow("image", img_origin)
    cv2.setMouseCallback("image", draw_quadrangle, param={"img":img_origin, "pts":pts, "pic":pic})

    cv2.waitKey(0)
    cv2.destroyAllWindows()

if __name__ == "__main__":
    main()
```



# ギャラリー

###藤子・F・不二雄ミュージアム
藤子・F・不二雄ミュージアムの窓枠がドラえもん第1話「未来の国からはるばると」の原稿をデザインしているというのは広く知られた事実だが、どれほど忠実に再現されているのか確認してみた。

|ミュージアム外観（Googleストリートビューより）|
|:-:|
|![f_museum.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/635be815-5ad4-db44-977f-bac003f69df6.jpeg)|

|合成結果|
|:-:|
|![f_museum_comic.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/fa79658d-d0f8-99be-3396-91da214705e8.jpeg)|

んー、悪くはないのだけど、よりによって最初のページの再現度が低いのは残念だ。

|比較|
|:-:|
|![dora_comparison.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/61ef6270-dae9-305b-820c-5f56401ab892.jpeg)|

### 悪の組織から宣戦布告を受け、ざわつく国際会議
![aybabtu.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/455ba0d8-8a50-3811-f36b-80338b4c7e9d.jpeg)
All your base are belong to us!
元画像は[こちら](https://pixabay.com/ja/photos/%E4%BC%9A%E8%AD%B0-%E3%82%A2%E3%83%87%E3%82%A3%E3%82%B9%E3%82%A2%E3%83%99%E3%83%90-%E3%82%A8%E3%83%81%E3%82%AA%E3%83%94%E3%82%A2-83519/)。

### 野外映画のスクリーンでダライアスをプレイ
![darius.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/2c3df600-5d0a-f5fc-2609-b0279c1b584d.jpeg)
元画像は[こちら](https://www.pakutaso.com/20180825239post-17167.html)。「つくば科学博の[ソニージャンボトロンでロードランナー](https://ja.wikipedia.org/wiki/%E3%82%B8%E3%83%A3%E3%83%B3%E3%83%9C%E3%83%88%E3%83%AD%E3%83%B3#%E3%83%91%E3%83%93%E3%83%AA%E3%82%AA%E3%83%B3%E3%81%A8%E3%81%97%E3%81%A6%E3%81%AE%E3%80%8CSONY_%E3%82%B8%E3%83%A3%E3%83%B3%E3%83%9C%E3%83%88%E3%83%AD%E3%83%B3%E3%80%8D)をプレイ」を作ろうと思ったのだが、[ツイッターで似たネタがあった](https://twitter.com/hashtag/%E3%82%B8%E3%83%A3%E3%83%B3%E3%83%9C%E3%83%88%E3%83%AD%E3%83%B3%E6%99%92%E3%81%97%E5%8F%B0%E3%81%A7%E3%83%97%E3%83%AC%E3%82%A4%E3%81%97%E3%81%9F%E3%81%84%E3%82%B2%E3%83%BC%E3%83%A0)のでやめた。

### 山手線の三連サイネージでダライアスをプレイ

|元画像「[交通広告ナビ](https://www.koutsu-navi.com/train/intrain/movie/index.html)」|合成結果|
|:-:|:-:|
|![train_ad.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/2e48091a-05ec-836d-8620-3dbc40252c11.jpeg)|![train_darius.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/025455e0-ccc6-5afb-7f24-77daaacb41cf.png)|

遊びづらそうだ。
実用的な例として斜めから撮らざるを得ない駅の超長いポスターを長方形に復元する事例を思いついたのだが、適した画像を見つけられなかった。


# 終わりに
これと矩形検出を組み合わせれば四角形を選択する必要もなくなるな。本来の画像サイズ（縦横比）を知っておく必要があることに変わりはないが。

# 参考記事
 - [OpenCV-Python 演習](https://www.oit.ac.jp/is/nishiguchi-lab/?OpenCV-Python+%E6%BC%94%E7%BF%92/tutorial_008py)<br>
コールバック関数のparamに辞書を渡すことで、コールバック関数内での辞書の変更が呼び出し元にも反映されるとのこと。これでグローバル変数的な使い方をしている。<br>
ググって見つけた大阪工業大学西口研究室の学生向けコンテンツなのだが、部外者が見ちゃっていいのかしら（トップページから演習にアクセスしようとするとユーザー名とパスワードを聞いてくる）。