【高専ロボコン】高専ロボコン2022 沖縄A 撃墜機構のプログラム(トラッキング)解説

# 高専ロボコン2022 沖縄A 撃墜機構のプログラム解説
## はじめに
**こちらでは大会のルールは説明していないため、ルール等に関しては[ロボコン公式サイト](https://official-robocon.com/kosen/)をご参照ください。**

こんにちは。
私は高専ロボコン2022 九州沖縄地区大会で、沖縄高専Aチームの撃墜機構(トラッキング)の制御を担当していました。
残念ながら地区大会で敗退という結果になってしまいましたが、製作者として初めてのロボコンで自分が製作に少しでも関わることができたうえ、他高専さんの素晴らしい技術やアイデアを間近で見ることができ、大満足の初ロボコンでした。
そんな初ロボコンで、相手の紙飛行機を撃ち落とす **「撃墜機構」** に用いるトラッキングを任され、頭を抱えながらも担当教員や友人と相談し、なんとかプログラムを完成させることができました。
ソースコードは[GitHub](https://github.com/wassy310/NHK_2022_Robocon_Okinawa_A_shooting-down)で公開しています。少しでも参考になれば幸いです。

:::note warn
後輩への技術継承も兼ねているので、来年以降のロボコンでも使えるような、汎用性の高い箇所(関数やライブラリの使い方など)に絞って解説しています。
:::

## 概要
画像認識により指定した色の物体(紙飛行機)を識別し、特定の範囲内で検出したらシリアル信号を送信します。
また、PCとカメラの仕様と開発・実行環境は次の通りです。
- PC
  - Windows 10 Pro 22H2
  - Intel Core i5 8250U
  - 8GB RAM
- Webカメラ
  - BUFFALO (BSWHD06MBK/N)
- シリアル通信受信側マイコン
  - NUCLEO-F446ZE
- 開発・実行環境
  - 上記PCにて
  - Visual Studio Code

## 使用ライブラリ
以下に使用したライブラリを示します。
- **OpenCV - ver 4.5.5** (library)
  Open Source Computer Vision Library
  画像・動画処理機能がまとまっている、超有能ライブラリ
  画像や動画内の物体の検出、位置の特定、動きの識別などが可能に
- **NumPy - ver 1.23.4** (library)
  機械学習や画像処理において、データが大量にほしいときに便利
  配列としてデータを管理できる
- **PySerial - ver 3.4** (package)
  Pythonとマイコンなどの機器との間でシリアル通信をしたいときに使う
  通信の設定、open、送受信、close全てが可能

## ソースコード解説
### 1 import部
Pythonでは `import xxx` で、モジュールなどをそのプログラムファイルの中で使えるように呼び出してあげる必要があります。
今回のプログラムでは、
```main.py
import cv2    # OpenCVの呼び出し
import numpy as np    # NumPyの呼び出し
import serial    # PySerialの呼び出し
```
というように書きました。

:::note info
**`import xxx as yyy` の `as yyy`って何？**

`as`の前に呼び出した`xxx`には、`yyy`という **名前を付ける** ことができます。
例えば、今回の`import numpy as np`では、`numpy`に`np`という名前を付けて呼び出していることになります。
そうすることで、名前の長いモジュールなどを短くし、その後のプログラム中でも **短い名前で使うことが可能** になります。
:::

### 2 main()関数
main()関数には処理内容を書いています。
本来であれば、 **関数を複数用意して、処理の流れを明確化する** というのが良いプログラマです^^
しかし、私のプログラムを見ていただければわかる通り、main()関数しかありません。
......はい！ということで、このわかりにくいコードを少しずつ解説します！

:::note info
**Tips :**
Pythonでは、基本的に処理は **プログラムファイルの上から実行** されます。
しかし、規模が大きくなってきたり、複数の処理があるファイルができる場合は、実質的なプログラムの開始位置というものがわかりにくくなってしまいます。
この開始位置のことを **エントリポイント** といいますが、これがあるとプログラムを **どういう順番で読めば良いかが一目でわかります。** 
そこで、Pythonではmain()関数を作成し、これをエントリポイントとして使用することが多く用いられています。
:::

### 2.2 カメラの定義
```main.py
# 7, 8行目
camera = cv2.VideoCapture(1)
ret, image = camera.read()
```
カメラの指定には、`cv2.VideoCapture()`を使用します。
()の中に入る引数は、以下のようになります。
- 0
  **デバイスID: 1**
  カメラが1台接続されている場合、そのカメラが指定される
  複数台接続されている場合、カメラの認識順序によってデバイスIDが変わる可能性があるため注意
- 1以上
  カメラを複数台接続する場合、1, 2, 3...と引数を大きくしていけばよい。

:::note info
**デバイスIDの確認方法**
- windowsの場合
  実際に`camera = cv2.VideoCapture()`を実行するのが最適解......?
  あまり詳しくわからないです...もしわかる方がいらっしゃいましたらコメントでお願いします。
- Linuxの場合
  **v4L2** (Video for Linux Two)を使用して確認可能
  以下のコマンドを実行
  ```
  v4l2-ctl --list-devices
  ```
  結果
  ```
  UVC Camera:
      /dev/video0
  ```
:::
また、カメラからの画像・映像の取得には、`read()`を使用します。
`カメラオブジェクト.read()`というように、 **read()コマンドの直前に自分で決めたオブジェクト名を指定** してあげましょう。
今回は、`camera = cv2.VideoCapture(1)`というように記述しているため、カメラオブジェクトは`camera`になります。
これで、どのカメラ映像を取得するかを決めます。(2台以上接続している場合は、カメラオブジェクトも台数分用意してあげましょう)

これを実行するとどうなるかというと、第2戻り値の **`frame`に、カメラから取得した画像・映像データが格納** されます。
また、このデータは各画素毎でRGBの値を所有している **3次元配列のデータ** になるため、NumPyで **配列データとして扱うことも可能** です。
ちなみに、第1戻り値の **`ret`は画像の取得ができているか(True/False)を見ることが可能** です。
例えば、
```
ret, frame = camera.read()
print(ret)
```
とした場合、
画像の取得に成功すれば`True`、失敗すれば`False`が返ってきます。
カメラさえ認識できていれば、大抵はTrueになります。
うまく認識できていない場合は、cv2.VideoCapture()のデバイスIDが合っているか確認してみましょう。

### 2.3 PySerial
```main.py
# 12, 13行目
ser = serial.Serial("COM7", 9600)
print(ser)
```
シリアルポートの設定をしています。
構文は以下の通りです。
`ser = serial.Serial("シリアルポート", シリアル通信速度[bps])`
どこのポートで、どれくらいの速度で通信しますか？ということですね。

```main.py
# 67, 68行目
ser.write(b"@")
print(ser.readline())
```
ここはif文の中にあるため、条件を満たした場合の処理になっています。
シリアルデータを送信するためには、`writeメソッド`を使います。
`write(送信したいデータ)`というかたちで指定してあげましょう。
また、 **バイナリデータを送信したい場合は、`b"データ"`というようにbで囲んであげます。**

一応確認程度に、自分(送信側PC)でも受信したデータを読み込めるようにしてあります。
受信したデータを読み込むときは、`readメソッド`を使います。
`ser.readline()`でデータを取得し、単純にprint文で表示させています。
```main.py
# 77行目
ser.close()
```
シリアル通信を終了するために、`closeメソッド`を使います。

### 2.4 色変換 (to HSV)
```main.py
# 20行目
frame_HSV = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
```
カラー画像 (映像)というのは、基本的にRGBデフォルトでで表されています。
しかし、RGB画像というものは、画像処理をするにあたって少し不便です。(下記 **HSV色空間のメリット** 参照)
そこで、HSV色空間に変換してあげることで、その不便な点を改善しよう、というのが目的です。
HSV色変換は、OpenCVを用いてすることが多いです。
cvtColor()関数を使います。
`cv2.cvtColor(入力画像, cv2.COLOR_BGR2HSV)`

:::note info
**HSV色空間のメリット**
RGBというのは、「原色の組み合わせ」で成り立っています。
しかし、HSVは「明るさ」や「鮮やかさ」といった要素で表すため、より直感的な調整が可能です。
:::

### 2.5 任意のキーを押したときの処理
```main.py
# 22行目
if cv2.waitKey(10) & 0xFF is ord('c'):   # press 'c' to select new color
```
Cキーを押したときの処理をコロン(:)の後に書いていきます。
`press 'c' to select new color`というコメントである程度わかりますが、 **「追跡対象の色を選択する際に、Cキーを押下することで選択モードに入る」** という処理になります。
以下、処理の内容です。

### 2.6 関心領域
:::note info
**ROI :**
Region Of Interestの略。日本語でいう「関心領域」「対象領域」など。
**画像の一部に対して処理を行うことが可能** です。
画像全体を入力として処理をしてしまうと、どうしても計算量が膨大になってしまいます。ROIを使えば、その心配もありませんし、コードの読みやすさも段違いです。
:::

今回は、マウスドラッグで範囲選択をしたかったため、OpenCVのselectROI()関数を使用しました。
`cv2.selectROI(ウィンドウ名, ソース画像, クロスヘア)`
- ウィンドウ名
  - 選択プロセスが表示されるウィンドウの名前
- ソース画像
  - ROIを選択するための画像
- クロスヘア
  - Trueの場合、選択した四角形の中に十字が表示され、中央がわかりやすくなる
  - 特に使わないなら邪魔になってしまうことも少なくないので、Falseで良いかも

今回のコードを見てみると、
```main.py
# 23行目
roi = cv2.selectROI('Original', image, False)
```

- 「Original」というウィンドウで表示
- 「image」という画像でROIを選択
- クロスヘア無し

という情報が書かれていることがわかります。

### 2.7 追跡対象の色の調整
```main.py
# 9行目
rr = 0   # +-mergin
```
```main.py
# 15, 16行目
h_min, s_min, v_min = (0, 0, 0)
h_max, s_max, v_max = (0, 0, 0)
```
```main.py
# 29, 30行目
h_min, s_min, v_min = int(np.min(h) + rr), int(np.min(s) + rr), int(np.min(v) + rr)
h_max, s_max, v_max = int(np.max(h) - rr), int(np.max(s) - rr), int(np.max(v) - rr)
```
前項で選択した色の調整をする部分です。
`rr`は調整度(デフォルトは調整無しの"0")。
15, 16行目でh, s, vそれぞれの最小値・最大値を初期化し、29, 30行目で各値をそれぞれ調整できるようにしてあります。
AIを使っているわけでもないので、微調整は必要になるだろうと思い、実装に至りました。

### 2.8 二値化
OpenCVで使われるinRange()関数は、二値化を専門とする関数です。

:::note info
**二値化とは**
対象画像を **白と黒のモノクロ画像に変換** する手法のこと。
背景と処理対象の物体をはっきり区別できるため、精度の向上や処理の軽量化が見込めます。
:::

`cv2.inRange(多次元配列(画像情報), 二値化条件の下限, 二値化条件の上限)`
となっています。
プログラム中では、次のように記述しています。
```main.py
# 32行目
thresh_HSV = cv2.inRange(frame_HSV, (h_min, s_min, v_min), (h_max, s_max, v_max))
```

### 2.9 モルフォロジー変換
```main.py
# 34, 35行目
mask = cv2.morphologyEx(thresh_HSV, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
```
:::note info
**モルフォロジー変換とは**
主に二値画像を対象とし、画像上に写っている図形に対して作用する処理を指す。
:::
モルフォロジー変換には様々な種類がありますが、今回は **「オープニング処理」「クロージング処理」** の2つに絞った変換をしました。
オープニング処理は、背景などのノイズ(画像の中のゴミみたいなやつ。小さい点が散在している場合がある)を除去するのに有効です。
逆にクロージング処理では、前景のノイズ除去をします。
- オープニング処理
`cv2.morphologyEx(入力画像, cv2.MORPH_OPEN, kernel)`
- クロージング処理
`cv2.morphologyEx(入力画像, cv2.MORPH_CLOSE, kernel)`

### 2.10 輪郭抽出
OpenCVのfindContours()関数により、二値化された画像中の白または黒の部分の輪郭のデータを取得することができます。
- この関数で取得できる情報
  - 輪郭を構成している座標群
  - 輪郭の外側、内側の情報

```main.py
# 36行目
cnts = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2]
```

構文は以下の通りです。
`findContours(入力画像, mode, method)`

- mode
  - 輪郭の階層情報の取得方法を指定する。
- method
  - 輪郭を構成する点の座標の取得方法を指定する。

また、上記2つ(mode、method)に関して[詳しい記事](https://imagingsolution.net/program/python/opencv-python/opencv-python-findcontours/)を見つけたのでご参照ください。

### 2.11 輪郭の描画
```main.py
# 37行目
cv2.drawContours(image, cnts, -1, color=(0, 0, 255), thickness=2)
```
引数の情報をもとに、画像上に輪郭を描写してくれる関数です。
構文は以下の通りです。
`drawContours(入力画像, 輪郭, contourIdx, color, thichness)`

- contourIdx
  - 描画する輪郭をindexで指定。マイナスを指定すると **全ての輪郭が描画** される。
- color
  - RGBで指定する。
- thichness
  - オプションで指定。線の太さを指定できる。

### 2.12 長方形の描画
```main.py
# 43行目
cv2.rectangle(image, (int(roi_area[2]), int(roi_area[3])), (int(roi_area[0]), int(roi_area[1])), (0, 255, 0), 2)
```
概要で「特定の範囲内で検出したらシリアル信号を送信」と書きましたが、この **「特定の範囲」** を決め、描写していたのがこの行です。
ROIで関心領域を指定した際に、各頂点の定義もしたので、それを使っています。

構文は次の通り。
`rectangle(入力画像, 長方形の左上頂点座標, 長方形の右下頂点座標, color, thickness, shift)`

- thickness
  - 線の太さの指定であることは先ほどと同じ。マイナスを指定すると **長方形の中を塗りつぶす** 処理になる。
- shift
  - 各座標において、 **小数点以下の桁を表すビット数。** あまり目に見える変化はないので、省略しても良いかも。省略した場合は0として扱われる。

### 2.13 円の描画
```main.py
# 55, 56行目
cv2.circle(image, (int(x), int(y)), int(radius), (0, 255, 255), 2)
cv2.circle(image, center, 3, (0, 255, 255), -1)
```
今回、指定した色のうち、最大面積の中央に円を表示するプログラムになっています。
構文はこう。
`circle(入力画像, 円の中心点の座標, 円の半径, color, thickness)`
こちらでも、thichnessがマイナスだと円の中が塗りつぶされます。

### 2.14 文字の描画
座標情報(円の中央座標など)を描画してかっこよくしたかったので、文字も入れてみました。
これしたら一気に撃墜機構っぽくなったので、個人的に一番好きかもしれません。
```main.py
# 57 ~ 59行目
cv2.putText(image, "centroid", (center[0] + 10, center[1]), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 0), 1)
cv2.putText(image, "(" + str(center[0]) + "," + str(center[1]) + ")", (center[0] + 10, center[1] + 15),
            cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1)
```
構文
`putText(入力画像, 描画したい文字, 長方形の右下頂点の座標, 文字のフォント, 文字の大きさ, color, thichness)`
thichnessは指定した値(px)で描画されます。マイナスを渡すとエラーが出るので注意。

### 2.15 撃墜(自弾発射)条件の指定
```main.py
# 63 ~ 68行目
if((270 < x < 370) and (200 < y < 300)):
    print("center(x,y), radius=", center, ",", radius)
    print("Go!")
    # send serial data
    ser.write(b"@")
    print(ser.readline())
```
長方形で描画した範囲内に入ったら、まずPCのターミナルに **「中央座標」「半径」「GO!」** を表示します。
また、2.3でも紹介した通り、ここでシリアル通信をしています。

## おわりに
簡単ではありますが、以上が主な処理・機能です。
わかりにくいところや「ここ教えてほしい！」というところがあれば、コメントでもDMでも口頭でもいつでもどうぞ！