🛣

Python3 + OpenCVで道路白線検出プログラムを実装する

2023/10/03に公開

はじめに

ドイツで自動車のソフトウェアエンジニアリングを学んでる奴 "shogura" です。

「Shaping the Future of mobility, together」を掲げ、自動車に関するソフトウェアエンジニアリングのプログラムを提供しているSEA:MEに参加しています。SEA:MEでは「組み込みシステム」「自動運転」「モビリティエコシステム」の3つのモジュールから自分の興味に合った分野を学習することができます。

本記事では、自動運転モジュールの一環であるPiRacerで決まったレーンを自動走行でさせるという課題の一部である道路白線検出プログラムについてどのように実装したのかをお話しします。

完成イメージ

開発環境

OS: Mac Version 13.0.1
Python: 3.11.5
OpenCV: 4.6.0.66

Anacondaを使用してOpenCV環境を構築するのが理想的ですが、Conda環境でOpenCVが正常に動作しなかったため今回はpipenvコマンドを使用して仮想環境を構築しました。

pipenvコマンドに関する詳細記事はこちら↓
https://zenn.dev/nekoallergy/articles/py-env-pipenv01

実装フロー

画像はRGBの3次元情報から成り立っています。しかし、3次元のまま画像処理を進めると計算コストが増加し、ノイズによる画像処理の失敗が起こる可能性があります。そのため、まずは閾値処理(Thresholding)を行い、画像を2値化します。白線検知を行う前に透視変換(Perspective Transform)を実施します。透視変換により、画像上部のノイズをカットすることができます。正常に白線が認識されている場合、透視変換後の画像には左右に2本の白線が描画されます。画像からホワイトピクセル(値: 255)の数を計算し、それを基にヒストグラムを作成します。白線がある部分ではヒストグラムが凸状になります。しかし、常に同じ位置に白線がくっきり描画されているわけではなく、正確なホワイトピクセルの位置を特定するのは難しい場合があります。そのため、スライドウィンドウ法(Slide Window Search)を用いて、ウィンドウごとに白線を検知し、位置を正確に特定します。

1. 閾値処理 Thresholding

閾値処理とは一定の閾値を設定してそれ以下以上であれば白(255)、それ以外は黒(0)と分類していき2値画像を描画する画像処理の手法の一種です。


https://github.com/Shuta-syd/opencv-lane-tracker/blob/main/src/lane.py#L73-L98

cv2.cvtColor()を使用して、画像をRGB空間からHLS色空間に変換します。HLS色空間では、色情報、明度、彩度を独立して表現できるため、画像処理タスクにおいてこれらの情報を分離して使用できる利点があります。精度向上のために、彩度、明度、および赤色に対してそれぞれ閾値処理を行い、cv2.bitwise_or()を使用してこれらの処理結果を結合します。さらに、明度に対してSobelフィルタを適用してエッジをさらに強調しています。

2. 透視変換 Perspective Transform

透視変換は画像の視点を変更する処理のことです。この処理を用いることで、画像の歪みを補正したり、画像の一部を切り取ったりすることができます。今回は画像上部のノイズを除去するために透視変換を実行します。


https://github.com/Shuta-syd/opencv-lane-tracker/blob/main/src/lane.py#L116-L147

今回の処理では、左の図からも分かる通り、事前に関心領域(変換前の座標点)を設定しています。cv2.getPerspectiveTransform()関数は、変更後の座標点roi_pointsと変換後の座標点desired_roi_pointsを利用して透視変換行列を計算します。そして、cv2.warpPerspective()関数を用いて、透視変換行列を利用して画像を変換します。この際、cv2.INTER_LINEARフラグを使用して線型補間を行い、画像の歪みを効果的に補正することができます。

3. ヒストグラム Histogram

今回はヒストグラムを用いて白線の位置を特定します。ホワイトピクセルの数を計算し、白線であろう部分はヒストグラム上で凸状になる仕組みです。道路の状況は刻々と変化するため、この方法は100%正確ではないかもしれませんが、今回の用途ではその高い精度を求めていないため、この方法で十分です


https://github.com/Shuta-syd/opencv-lane-tracker/blob/main/src/lane.py#L155-L187

self.histogram = np.sum(frame[:frame.shape[0]:, :], axis=0)では画像の列ごとのピクセル合計を集約した1次元配列self.histogramを作成しています。histogram_peak()ではピクセル数が最も多い列を左右でそれぞれ特定しています。この関数はスライドウィンドウ法で使用します。

スライドウィンドウ法とは前回のヒストグラムで特定したピークから画面の下部から上部に垂直方向にウィンドウをスライドさせながら白線の位置を特定していく方法です。


https://github.com/Shuta-syd/opencv-lane-tracker/blob/main/src/lane.py#L190-L291

good_left_inds = ((nonzeroy >= win_y_low) & (nonzeroy < win_y_high) & (nonzerox >= win_xleft_low) & (nonzerox < win_xleft_high)).nonzero()[0]
good_right_inds = ((nonzeroy >= win_y_low) & (nonzeroy < win_y_high) & (nonzerox >= win_xright_low) & (nonzerox < win_xright_high)).nonzero()

win_y_high > nonzeroy >= win_y_low && win_xleft_high > nonzerox >= win_xleft_low以下の条件を満たすピクセスを抽出してgood_left_indsに格納します。good_left_indsはウィンドウ内に存在するピクセルのインデックスを格納した1次元配列です。good_right_indsも同様にして作成します。

minpix = self.minpix
if len(good_left_inds) > minpix:
  leftx_current = np.int32(np.mean(nonzerox[good_left_inds]))
if len(good_right_inds) > minpix:
  rightx_current = np.int32(np.mean(nonzerox[good_right_inds]))

good_left_indsの長さがminpixより大きい場合、good_left_indsの平均値つまり白線の方向を再設定してleftx_currentに格納します。

leftx = nonzerox[left_lane_inds]
lefty = nonzeroy[left_lane_inds]
rightx = nonzerox[right_lane_inds]
righty = nonzeroy[right_lane_inds]

left_fit = np.polyfit(lefty, leftx, 2)
right_fit = np.polyfit(righty, rightx, 2)

leftxleftyは左の白線のピクセルのインデックスを格納した1次元配列です。rightxrightyも同様に右の白線のピクセルのインデックスを格納した1次元配列です。np.polyfit()leftyleftxを用いて2次関数の係数を計算しています。これにより、容易に曲線および直線を描画できます。

out_img = np.dstack((frame_sliding_window, frame_sliding_window, (frame_sliding_window))) * 255

frame_sliding_windowは単一のチャンネル(グレースケール)のため、これを3チャンネル(RGB)に変換するためにnp.dstack()を使用しています。

5. 白線描画 Draw Lane

pts_left = np.array([np.transpose(
  np.vstack([self.left_fitx, self.ploty])
  )])
pts_right = np.array([np.flipud(np.transpose(
  np.vstack([self.right_fitx, self.ploty])
  ))])
pts = np.hstack((pts_left, pts_right))

cv2.fillPoly(color_warp, np.int_([pts]), (0, 255, 0))

np.vstack()を使用して両車線のX座標とY座標を垂直に結合します。垂直に系都合しているため以下のような状態になっており、(x,y)のペアになっていません。

[[x座標]
[y座標]]

(x,y)のペア状態に変換するためにnp.transpose()を使用します。np.transpose()は行列の転置を行います。右斜線の配列をnp.flipud()せずに(x,y)ペアが昇順の状態でソートされているcv2.fillPoly()に渡すと台形が描画されずおかしな形で描画されてしまいます。なぜなのかわからずここで詰まりましたが、cv2.fillPoly()は時計回りに描画されているのではないかと思いcv2.flipud()で上限反転させてみたところ、正常に描画されました。

newwarp = cv2.warpPerspective(color_warp, self.inv_transformation_matrix, (self.orig_frame.shape[1], self.orig_frame.shape[0]))

color_warp変数はオリジナルフレームを透視変換した画像に検知したレーンを塗りつぶしている状態ですので、変換行列self.inv_transformation_matrix = cv2.getPerspectiveTransform(self.desired_roi_points, self.roi_points)を利用して元の仕様に変換します。

6. 曲率計算 Radius of Curvature

道路の曲線は、道路がカーブしている程度やカーブの半径を示す値です。一般的に、曲率が大きいほど緩やかで、曲率が小さいほど急なカーブ示します。

left_fit_cr = np.polyfit(self.lefty * self.YM_PER_PIX, self.leftx * (self.XM_PER_PIX), 2)
right_fit_cr = np.polyfit(self.righty * self.YM_PER_PIX, self.rightx * (self.XM_PER_PIX), 2)

左右白線の点群lefty, leftx``righty, rightxを用いて最小二乗法で2次の多項式曲線を求めています。画像上の距離ではなく実世界での曲率を求めるのが目的のためピクセルからメートル方式に変換してフィットさせています。

left_curvem = ((1 + (2*left_fit_cr[0] * y_eval * self.YM_PER_PIX + left_fit_cr[1])**2)**1.5) / np.absolute(2*left_fit_cr[0])
right_curvem = ((1 + (2*right_fit_cr[0] * y_eval * self.YM_PER_PIX + right_fit_cr[1])**2)**1.5) / np.absolute(2*left_fit_cr[0])

曲率計算の公式の詳細は以下の記事を参照してください。
https://manabitimes.jp/math/952
https://www.intmath.com/applications-differentiation/8-radius-curvature.php

おわりに

本記事では、自動運転モジュールの一環であるPiRacerで決まったレーンを自動走行でさせるという課題の一部である道路白線検出プログラムについてどのように実装したのかをお話ししました。

私が参加しているSEA:MEプログラムは、自動車のソフトウェアエンジニアリングについて学ぶことができるプログラムです。SEA:ME / 42に興味がある方は、ぜひお気軽にメッセージください。
https://seame.space/
https://42tokyo.jp/

Discussion