OpenCVのFastLineDetectorとHoughLinesで映像の消失点を推定、カメラ向きの把握

# はじめに  
- OpenCVの機能の１つに、画像の直線検出があります。
- この機能を使って、カメラ映像の消失点を推定。カメラの向きの変化の把握を試みました。
- ジャイロセンサを持たないカメラで、姿勢認識がどこまでできるか試したかったからです。
### 前提
 消失点の推定にはいろんな方法があり、それぞれ得手/不得手があります。
- 検出直線の数が少ない画像は、直線同士の交点を求めるのが確実です。特徴的な線が必要で、対象ケースは限定的です。
- 検出直線の数が多い画像は、直線の傾きａと切片bの座標でグラフを描いて推定できます。
- 長い直線のみに限定するとノイズが減りますが、推定しづらくなります。
- 短い直線を含めると推定しやすいですが、ノイズも増えるため、誤りやすくもなります。

今回のやり方は、信頼できる平行直線がたくさんある画像向けです。ビルや直線道路などのある街中など。逆に自然風景は苦手です。

### 工夫したこと
1. 画像から検出した直線から消失点を求めるのに、直線の傾き*a*と切片*b*の座標でグラフを描く方法を選びました。なるべくいろんな画像で使えるようにするためにです。

2. $(a, b)$ のグラフを $(1/a, b/a)$ と逆数で描きました。正面に消失点が来ても計算しやすいようにです。

3. $(1/a, b/a)$ グラフで直線検出しやすいよう、スケーリングするようにしました。

4. 消失点を求めるのに $(1/a, b/a)$ グラフ内の直線を探すのですが、ここでも画像の直線検出を使いました。

5. 2つ目の消失点を求める前に、1つ目の消失点を求めるのに使用した点を消す処理を入れました。2つ目を見つけやすくするためです。

# 内容
### 1. PC環境
　実施したPC環境は以下のとおりです。

| | |
|:--:|:--:|
|CPU|Celeron N4100|
|メモリ|8GB LPDDR4|

### 2. 前準備
　Windowsで使えるようにするため、以下のツール / システムをインストールしました。
インストール時に参考にしたサイトも記載します。

1. python（使用したのはVer.4.7.0.72 ）
　実行時のベースシステムです。
（参考）https://qiita.com/ssbb/items/b55ca899e0d5ce6ce963
2. pip（使用したのはVer.21.2.4 ）
　他のツールをダウンロードする際に使うツールです。
（python3系ではバージョン3.4以降であれば、pythonのインストールと共にpipもインストールされます。）
（参考）https://gammasoft.jp/python/python-library-install/
3. OpenCV（使用したのはVer.4.5.3 ）
　画像系処理するためのライブラリです。
（参考）https://qiita.com/ideagear/items/3f0807b7bde05aa18240

### 3. pyファイルの作成
 組んだコードは次のとおりです。長いです。

~~~python
import sys
import cv2
import numpy as np

from PIL import Image

#-- 2点から傾きと切片を求める関数
def linear(n1, n2):
  if n1[0] == n2[0]:
      return [0, n1[0]]
  a = (n2[1]-n1[1])/(n2[0]-n1[0])
  b = (n2[0]*n1[1]-n1[0]*n2[1])/(n2[0]-n1[0])

  return a, b

#-- rhoとthetaから途中計算する関数
def calc_line(theta, rho):
  a0 = np.cos(theta)
  b0 = np.sin(theta)
  x0 = a0 * rho
  y0 = b0 * rho

  return -a0/b0*x0-y0, -a0/b0 

#-- rhoとthetaから直線を描く関数
def draw_line(img1, img2, theta, rho):
  a0 = np.cos(theta)
  b0 = np.sin(theta)
  x0 = a0 * rho
  y0 = b0 * rho
  x1 = int(x0 + 1000*(-b0))
  y1 = int(y0 + 1000*(a0))
  x2 = int(x0 - 1000*(-b0))
  y2 = int(y0 - 1000*(a0))

  cv2.line(img1, (x1, y1), (x2, y2), (0, 0, 255), 1)
  cv2.line(img2, (x1, y1), (x2, y2), 0, 10)

#-- メインルーチン
if __name__ == "__main__":

  length_threshold = 60 # 30 ～ 100
  distance_threshold = 1.41421356

  canny_th1 = 50
  canny_th2 = 50
  canny_aperture_size = 3
  do_merge = True

#-- 数値表示桁数の設定
  np.set_printoptions(precision=3)

#-- 映像元を設定
#  camera = cv2.VideoCapture(0)                # カメラCh.(ここでは0)を指定 <-- レスポンスまで結構時間がかかる
  camera = cv2.VideoCapture("./M6.mp4")                # 動画を指定


#-- Enterキーを押すまで一時停止
  a = input("hit enter key")

#-- ループ処理 開始
  while True:

#--  フレームを取得
    ret, img1 = camera.read()              

#--  フレームが取れなかったらループを抜ける
    if ret == False:
        break

#-- gray化 カラーのままだとエラーするため
    img = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY) 

#-- 画像サイズを調整 大きすぎるとエラーするため
    pil_img1 = Image.fromarray(img)
    rat = int((pil_img1.height + pil_img1.width) / 2000 + 1)
    (width, height) = (pil_img1.width // rat, pil_img1.height // rat)
    small_img = cv2.resize(img, dsize = (width, height))

#-- 直線検出 FastLineDetect内で使うCanny変換のパラメータ計算
    med_val = np.median(small_img)
    sigma = 0.33  # 0.33
    min_val = int(max(0, (1.0 - sigma) * med_val))
    max_val = int(max(255, (1.0 + sigma) * med_val))

    canny_th1 = min_val
    canny_th2 = max_val

#-- 直線検出関数を定義
    fld = cv2.ximgproc.createFastLineDetector(
        length_threshold,
        distance_threshold,
        canny_th1,
        canny_th2,
        canny_aperture_size,
        do_merge
    )

#-- 画像内の直線検出
    lines = fld.detect(small_img) #grayImageでないと動かないらしい

#-- 検出直線を画像に追記した画像を作成
    out = fld.drawSegments(small_img, lines)

#-- 検出直線の傾きaと切片bから（1/a, b/a）の点列tmplistを作成
    tmplist = []
    if lines is not None:
        for line in lines:
            ai, bi = linear([line[0][0],line[0][1]],[line[0][2],line[0][3]])
            if ai!= 0:
                tmplist.append([1/ai, bi/ai])

#-- tmplistをnumpy.array ablistに変換
        ablist = np.array(tmplist)

#-- ablistを（1/a, b/a）平面の画像にプロット
        maxa, maxb = ablist.max(axis=0)
        mina, minb = ablist.min(axis=0)

        if mina<maxa and minb<maxb :
            ta = 260/(maxa-mina)
            tb = 260/(maxb-minb)

            imaxa = int(maxa*ta)
            imina = int(mina*ta)
            imaxb = int(maxb*tb)
            iminb = int(minb*tb)

            abimg = np.zeros((imaxb-iminb+1,imaxa-imina+1),dtype = np.uint8)

            for a2, b2 in ablist:
                abimg[int(b2*tb)-iminb, int(a2*ta)-imina] = 255

#-- プロットした画像を複製
            abimg2 = cv2.cvtColor(abimg, cv2.COLOR_GRAY2RGB)
            abimg3 = abimg.copy()

#--- 1つめの消失点 推定-----------------------------------------------------------------
            ablines = cv2.HoughLines(abimg3, rho=0.5, theta=np.pi/360, threshold=1)
            if ablines is not None:
              for abline in ablines[:1]:
                  vpx, vpy = calc_line(abline[0][1],abline[0][0])
                  draw_line(abimg2, abimg3, abline[0][1],abline[0][0])
                  #消失点座標(x,y)
                  yt = ta/tb*vpy
                  xt = 1/tb*vpx+ta/tb*vpy*imina/ta-iminb/tb
                  #消失点の方位角と仰角を計算
                  xs1 = np.arctan((xt*2*rat-pil_img1.width)/(pil_img1.width) )*180/np.pi
                  ys1 = np.arctan((yt*2*rat-pil_img1.height)/(pil_img1.height) )*180/np.pi

#--- 2つめの消失点 推定-----------------------------------------------------------------
            ablines = cv2.HoughLines(abimg3, rho=0.5, theta=np.pi/360, threshold=1)
            if ablines is not None:
              for abline in ablines[:1]:
                  vpx, vpy = calc_line(abline[0][1],abline[0][0])
                  draw_line(abimg2, abimg3, abline[0][1],abline[0][0])
                  #消失点座標(x,y)
                  yt = ta/tb*vpy
                  xt = 1/tb*vpx+ta/tb*vpy*imina/ta-iminb/tb
                  #消失点から方位角と仰角を計算
                  xs2 = np.arctan((xt*2*rat-pil_img1.width)/(pil_img1.width) )*180/np.pi
                  ys2 = np.arctan((yt*2*rat-pil_img1.height)/(pil_img1.height) )*180/np.pi

                  #消失点の方位角と仰角の並びを揃える
                  if xs1<xs2 :
                       print(np.array([ys1,xs1,ys2,xs2]))
                  else :
                       print(np.array([ys2,xs2,ys1,xs1]))

#--           （1/a, b/a）平面の画像の検出直線の表示
#            cv2.imshow('ab_image2',abimg2)

#--            検出直線の画像表示
            cv2.imshow('detected',out)

    # キー操作があればwhileループを抜ける
            if cv2.waitKey(1) & 0xFF == ord('q'):
              break
#--  ループ処理 ここまで

#--  終了処理
  camera.release()
  cv2.destroyAllWindows()
~~~

### 4. 説明

#### 1. 検出直線の傾き*a*と切片*b*から消失点を計算する方法

 消失点を求める理屈の説明です。$n$ 個の直線のうち $i$ 番目の直線の式は
$$y=a_{i}x+b_{i}$$で表せます。それらの直線がすべて同じ消失点 $(x_{0}, y_{0})$ を通るならば、
$$y_{0}=a_{i}x_{0}＋b_{i}$$が成り立ちます。式を変形すれば
$$b_{i}＝-x_{0}a_{i}+y_{0}$$と表せ、そのため $(a,b)$ のグラフに $(a_{i}, b_{i})$ をプロットすれば、それらの点は傾き $(-x_{0})$、切片$y_{0}$ の直線上に並ぶはずです。
この直線を求めることで消失点 $(x_{0}, y_{0})$ がわかります。

#### 2. (*a*, *b*)の代わりに(*1/a*, *b/a*)を使うことのメリット/デメリット

i番目の直線を表す式
$$y=a_{i}x+b_{i}$$を変形すれば
$$b_{i}/a_{i}=y(1/a_{i})-x$$
と表せ、$(1/a, b/a)$ のグラフに $(1/a_{i}, b_{i}/a_{i})$ をプロットすれば、先ほどと同じ理屈で、それらの点は傾き $y_{0}$ 、切片 $(-x_{0})$ の直線上に並ぶはずです。
この直線を求めることでも消失点 $(x_{0}, y_{0})$ が求まります。

$(a, b)$ の代わりに $(1/a, b/a)$ を使うメリットは、真っすぐ垂直な直線を扱えることです。逆にデメリットは真っすぐ水平な直線を扱えないことです。
もう1つ。一般的な画像であれば水平線はだいたい画像に水平になるため、水平線上の2つの消失点 $(x_{0}^{1}, y_{0}^{1}), (x_{0}^{2}, y_{0}^{2})$ は
$$y_{0}^{1}=y_{0}^{2}$$ $$x_{0}^{1} \neq x_{0}^{2}$$ となり、 $(1/a, b/a)$ のそれぞれを表す直線は平行な別の直線になり見分けやすくなるはずと考えました。

#### 3. グラフのスケーリング

 $(1/a, b/a)$ の配列ですが、配列が小さすぎるとプロットがつぶれて傾きが求められなくなります。また、配列が大きくなると **HoughLines** で読み込めなくなります。
配列が $260 \times 260$ 程度になるようスケーリングして使用してます。

~~~python
        maxa, maxb = ablist.max(axis=0)
        mina, minb = ablist.min(axis=0)

        if mina<maxa and minb<maxb :
            ta = 260/(maxa-mina)
            tb = 260/(maxb-minb)

            imaxa = int(maxa*ta)
            imina = int(mina*ta)
            imaxb = int(maxb*tb)
            iminb = int(minb*tb)
~~~

#### 4. グラフの直線検出

 $(1/a, b/a)$ グラフの直線検出のため、**numpy.zeros**の配列を作り $(1/a_{i}, b_{i}/a_{i})$ のところだけ255に値を変更、その配列を直線検出関数 **HoughLines** にかけてます。

~~~python
            abimg = np.zeros((imaxb-iminb+1,imaxa-imina+1),dtype = np.uint8)

            for a2, b2 in ablist:
                abimg[int(b2*tb)-iminb, int(a2*ta)-imina] = 255

            ablines = cv2.HoughLines(abimg3, rho=0.5, theta=np.pi/360, threshold=1)
~~~

#### 5. 使用した点の削除

プロット点から直線検出してるため、同じ点列から複数の直線を推定してしまいます。
それを避けるため、1つ目の直線を検出後、その直線を幅 10pt の太線で描き、プロットを消すようにしました。

~~~python
  cv2.line(img2, (x1, y1), (x2, y2), 0, 10)
~~~

### 5. 結果
　実カメラでも試しましたが、CG映像を作成して、検出の傾向がわかるようにして試しました。
トライで使った映像は以下のようなものです。
![M6b.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2449065/5d33beeb-0a3d-3bb1-4e09-9244b29c1da2.gif)
検出結果は例えば、以下の画像のようになりました。
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2449065/03ee160e-4291-3c0f-bc5c-a213d2bc3d70.jpeg">![result2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2449065/bd5b8451-1468-37df-1bcb-1bc25385eaa5.jpeg)
検出の傾向として、以下のようなものが見られました。
- 消失点が画像の左右にあるようなケースでは±1度くらいの精度で認識することができました。
- 消失点が正面、また左右の遠く離れたところにあるケースでは、精度が出ず、答えが安定しませんでした。

# 参考

参考サイト
直線検出方法と消失点のアイデア）
https://nsr-9.hatenablog.jp/entry/2021/08/12/200000

消失点の検出方法）
http://opt.imi.kyushu-u.ac.jp/~fujisawa/yc_detect.pdf

Canny法のパラメータ）
https://kuroro.blog/python/wOt3yEohr7oQt1qzif71/
https://qiita.com/kotai2003/items/662c33c15915f2a8517e