ビリヤード台の斜め撮り画像から真上撮り画像への変換

# はじめに
私の父はビリヤード（スリークッション）が好きで、何十年もやってて、アマチュアの全国大会でたまに勝つらしいです。
そんな父が、カメラ撮影の斜め上からじゃなくて真上画像がほしいと言っていたので、技術的にできるか試してみました。

技術のお試しなので、やっつけ感がありますが、コードはこちら。

https://github.com/yo16/perspective_transformation_prac

環境
- Python 3.12.3
- numpy 1.26.4
- opencv-python 4.9.0.80

## 先に結果
YouTube拝借したこちらを画像にして入力して
![sample4.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/2ec06847-508e-a224-b8c3-d31601c22edc.png)
[第76回全日本3C選手権決勝：森雄介 vs 新井達雄 - YouTube](https://www.youtube.com/watch?v=uVrIi9517pM)

これを出せます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/d9e8442a-a913-d832-a956-35dcc5589d39.png)

うーん、赤玉は明らかにクッションについてないんだよなー、というご指摘、ごもっともでございます。ビリヤードはくっついてるかどうかが、とても大きな問題。調整が必要です。

# 技術ざっくり説明
詳細は、[github](https://github.com/yo16/perspective_transformation_prac)からコードをご覧ください:desktop:
main3.pyという謎のファイル名になっています:relaxed:

## 処理の概要
① ビリヤード台の角を検出
② ボールの位置を検出
③ 透視変換（Perspective Transformation)で、台の画像とボールの位置を長方形へ変換

## ①ビリヤード台の角を検出
まず、青色で画像全体をマスクして、青色の部分を白へ、それ以外を黒へ、２値に変換します。
マスクをする際には、`cv2.inRange()`関数を使って、HSVで色を範囲指定します。

次に、その２値画像に対して、`cv2.findContours()`関数を使って、境界線を取得します。

`cv2.approxPolyDP()`を使って、境界線を線にして、その線から四角形の台、そしてその角を検出します。

```python:main3.py(輪郭を抽出するところ)
# 輪郭を検出するための準備：前処理としてマスク画像を取得する
hsv = cv2.cvtColor(cv_image, cv2.COLOR_BGR2HSV)
lower_blue = np.array([90, 50, 50])  # 青色のHSV範囲の下限
upper_blue = np.array([140, 255, 255])  # 青色のHSV範囲の上限191
mask = cv2.inRange(hsv, lower_blue, upper_blue)
contours, _ = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

# 最大輪郭を見つける
largest_contour = max(contours, key=cv2.contourArea)

# 輪郭の近似を行い、四角形の頂点を見つける
perimeter = cv2.arcLength(largest_contour, True)
epsilon = 0.02 * perimeter
approx = cv2.approxPolyDP(largest_contour, epsilon, True)
```

## ② ボールの位置を検出

検出する部分は台とほぼ同じなので割愛。２値マスクからの`cv.findContours()`です。

ボール特有の課題としては、球なので、テカりから影が漏れなくあって、ちゃんと球（画像としては円）の認識ができないことです。

黄色い球は、明るいところはほぼ白。だから白を検出したいとき、黄色の明るいところも検出しちゃう。白と黄色が重なっているときに悲劇。

白い球は、クリーム色なので、暗いと茶色になる。

なので、円が認識できない前提、そしてノイズが入る前提で、ノイズカットが必要です。

そこで、画像のノイズカットの技術の「オープニング」と「クロージング」という方法を使います。
オープニングは、境界外郭を薄く削って、そのあとに縁取りするように肉付けします。これをやると、削るときにノイズがなくなるので、再び肉付けする元がなくなって、ノイズが消えます。
クロージングは、順序が逆。先に境界外郭を縁取りするように外へじわっと膨らませて、もう一度削る。これをやると、縁取りするときに穴が塞がる。

kernelは、膨らませ方です。縦方向に伸ばすとか調整できます。全方向以外、使うのかわからないけど。
```python:main3.py（オープニングとクロージングのところ）
# オープニング（縮小して拡大、ノイズ除去）
kernel1 = np.ones((5,5), np.uint8)
opening_image = cv2.morphologyEx(masked_image, cv2.MORPH_OPEN, kernel1)

# クロージング（拡大して縮小、穴埋め）
kernel2 = np.ones((5,5), np.uint8)
closing_image = cv2.morphologyEx(opening_image, cv2.MORPH_CLOSE, kernel2)
```

これをやることによって、こんな部分を除去しました。
白い球を検出する場面ですが、黄色が指輪のように重なり、赤玉のテカリの部分も検出されてます。

元画像
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/f971823b-3e15-0ada-bd66-9c8e431749a7.png)

処理前
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/ecc184b7-cb23-8535-f130-dc36c73357dd.png)

オープニング後
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/faa4ab87-088f-c8a1-0cf8-29886e5a789d.png)

オープニング＋クロージング後
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/d991fc1e-bd78-315a-7897-ccf11aac4486.png)

このケースでは、クロージングはあんまり意味がなかったかもですが、会場の明るさや影の状態によっては、使えると思います。


## ③ 透視変換（Perspective Transformation)で、台の画像とボールの位置を長方形へ変換

たぶんここが大物というか、ここを期待して見ていただいていると思います。ですが、正直コーディングはあまり大したことはないです。

というのも、既に①でビリヤード台の角を検出していて、それをどこに持っていくか（左上と右上と右下と左下の長方形で、台のサイズは固定です）は決まっているので、その変換をして！ってOpenCVに依頼するだけです。実は大物は①でした。

```python:main3.py（透視変換の部分）
# 真上から見たビリヤード台の四隅の座標 (x, y) (通常は四角形)
dst_points = np.float32([
    [0, 0],
    [width, 0],
    [width, height],
    [0, height]
])

# ホモグラフィ行列を計算する
matrix = cv2.getPerspectiveTransform(src_points, np.array(dst_points))
matrix = np.float32(matrix)

# 透視変換を実行する
warped_image = cv2.warpPerspective(original_image, matrix, (width, height))
```
`src_points`は画像の中の台の四隅で、`dst_points`は最終系の台の形なので、widthとかで決まってます。

その変換行列（ホモグラフィ行列）というものをゲットして、imageに適用させれば完成！

この変換行列は、２次元の変形でよく出てくる３ｘ３の行列で、仕組みもそんなに難しいものではないですが、知らなくてもできます。

画像全体の変換の後、この行列を利用して、ボールの元の位置から変換後の位置への座標変換もしました。そして変換後の位置にOpenCVの円を描く関数で円を描いてます。画像は円が変形してしまうので。私は写真の上に描きましたが、何かのシステム化するのであれば、新しい画像に描けばいいだけ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/d9e8442a-a913-d832-a956-35dcc5589d39.png)

# おわりに
ビリヤード場は、暗かったり明るかったり、テーブルの色も真っ青だったり緑っぽかったりするので、汎用性を高めるのは難しいかもしれないです。今の段階ではできているけど。

話を最初に戻して父に、技術的にはこんなのできそうだけどって報告したら、これをどう使いたいのか具体が全然なく、システム化に向けては要件不足でとん挫しました。ご意見ご要望などがあれば、コメント欄でどうぞ！