画像認識した白線に沿って自走するラズマウス5号機の製作記録

ラズマウスに取り付けたカメラ画像を、OpenCVを使って白線を認識して、左右白線の中央線に沿って自走するラズマウス5号機の製作記録。
左右白線のセンターとラズマウスの進行方向のズレ量を最小にするようにPID制御して自走する。
●白線を認識するラズマウスの構成部品

<WEBカメラの位置と撮影範囲>

●白線を認識するプログラム構成
白線の認識は、OpenCVでカメラ画像を二値化し、輪郭から白線候補を選択し、左右白線の条件で絞り込みを行った。そのプログラム例は次のモジュールで構成。
rasPosition:白線を認識して、左右白線の中心線からラズマウスの進行方向のズレ量(pixel)を算出
├(1)detectWhiteLines:白線候補を抽出
│ ├①cv2.cvtColor:入力画像をグレースケール化
│ ├②cv2.threshold:グレースケール画像を閾値で抜き出し
│ ├③cv2.findContours:RETR_TREEで全輪郭を検出し全階層情報を得て、その中から、
│ │ hierarchyの親が無く(-1)、親parentが0の輪郭のみ抽出。
│ └④isThisWhiteLine:抽出した輪郭が白線かどうかを評価
│ 評価条件:最小長さ>50pixel、最小太さ(Width/Length)>0.05、最大太さ(W/L)<0.3
│ 傾き>0.3(撮影方向に対して横向きの輪郭は除く)
├(2)selectVerticalLines:白線候補の中から進行方向に沿う縦方向の2本の白線を選択
├(3)drawWhiteLines:選択した白線を画面に描画
└(4)calcCenterPos:左右白線の中心線からラズマウスの進行方向のズレ量(pixel)を算出
<白線認識のプログラム事例>
# -*- coding: utf-8 -*-
import numpy as np
import cv2
import time
COL, ROW = 640, 480
CV_FONT = cv2.FONT_HERSHEY_SIMPLEX
BLUE = (255, 0, 0)
GREEN = (0, 255, 0)
RED = (0, 0, 255)
YELLOW = (0, 255, 255)
# 白線選択の条件
LINE_MIN_LEN = 50 # 最小長さ(pixel)
LINE_MIN_RATIO = 0.05 # 最小太さ W/L > 0.05
LINE_MAX_RATIO = 0.3 # 最大太さ W/L < 0.3
class CONT_LINE:
def __init__(self, contNo, direct, center, boxNP, lineC, lineL):
self.contNo = contNo # 輪郭番号
self.direct = direct # 縦線(0)/横線(1)
self.center = center # 重心位置[x,y]:pixel
self.boxNP = boxNP # 白線外形矩形[[x0,y0],[x1,y1],[x2,y2],[x3,y3]]:pixel
self.lineC = lineC # センターライン[[xs,ys],[xe,ye]]:pixel
self.lineLength = lineL # 白線長さ(boxの長辺)
class WHITE_LINES:
def __init__(self, thresh):
self.frame = 0
self.thresh = thresh
self.prevPosCol = int(COL / 2)
self.prevOffsetL = 0
self.prevOffsetR = 0
self.prevL = 0
self.prevR = 0
def calcCenterPos(self, whiteLines, indexL, indexR):
if indexL > -1 and indexR > -1: # 2本の白線がある場合
xsL = whiteLines[indexL].lineC[0][0] # 左白線のxs
xsR = whiteLines[indexR].lineC[0][0] # 右白線のxs
posCol = int((xsL + xsR) / 2)
self.prevOffsetL = posCol - xsL
self.prevOffsetR = posCol - xsR
elif indexL > -1: # 左の白線しかない場合
xsL = whiteLines[indexL].lineC[0][0] # 左白線のxs
posCol = xsL + self.prevOffsetL # 左白線から右へoffset
xsR = posCol - self.prevOffsetR
else: # indexR > -1 右の白線しかない場合
xsR = whiteLines[indexR].lineC[0][0] # 右白線のxs
posCol = xsR + self.prevOffsetR # 右白線から左へoffset
xsL = posCol - self.prevOffsetL
return posCol, xsL, xsR
def drawWhiteLines(self, whiteLines, indexL, indexR):
# 選択された白線(indexL, indexR)を描画
lines = [indexL, indexR]
for i in range(2):
if lines[i] != -1:
boxNP = whiteLines[lines[i]].boxNP
[[x0,y0],[x1,y1]] = whiteLines[lines[i]].lineC
cv2.line(self.frame, (x0, y0), (x1, y1), GREEN, 2) # センターライン
cv2.drawContours(self.frame, [boxNP], 0, RED, 2) # 認識白線を赤で囲む
c1x, c1y = int(COL/2), 0
c2x, c2y = int(COL/2), ROW
cv2.line(self.frame, (c1x, c1y), (c2x, c2y), YELLOW, 2) # ラズマウス進行方向
return
def isThisWhiteLine(self, cnt, box, rectR, minLen, minRatio, maxRatio):
# 白線候補を抽出、lineStat=True、中心線:cy = a * x + b
if rectR[1][0] > rectR[1][1]:
boxL, boxW = rectR[1][0], rectR[1][1]
else:
boxL, boxW = rectR[1][1], rectR[1][0]
if boxL < 0.001:
boxRatio = 10000
else:
boxRatio = boxW / boxL
if boxL >= minLen and (boxRatio > minRatio and boxRatio < maxRatio):
lineStat = True
M = cv2.moments(cnt)
cx = int(M['m10']/M['m00']) # col
cy = int(M['m01']/M['m00']) # row
boxLen0 = (box[1][0] - box[0][0]) ** 2 + (box[1][1] - box[0][1]) ** 2
boxLen1 = (box[2][0] - box[1][0]) ** 2 + (box[2][1] - box[1][1]) ** 2
if boxLen0 < boxLen1:
bp = [[0, 1], [2, 3]]
else:
bp = [[1, 2], [3, 0]]
bpCx, bpCy = [0,0], [0,0]
for k in range(2):
bpCx[k] = int(box[bp[k][0]][0] + (box[bp[k][1]][0] - box[bp[k][0]][0]) / 2.)
bpCy[k] = int(box[bp[k][0]][1] + (box[bp[k][1]][1] - box[bp[k][0]][1]) / 2.)
vx = bpCx[1] - bpCx[0]
vy = bpCy[1] - bpCy[0]
# 白線の傾きvy/vx、点(bpCx[0],bpCy[0])を通る直線, cy = a * x + b
if abs(vx) > 0.0: # 垂直でない場合
a, c = vy/vx, 1
b = bpCy[0] - a * bpCx[0]
else: # 垂直(vx=0)の場合, x=-b
a, b, c = 1, -bpCx[0], 0
else: # 基準を満たさないbox
a, b, c, cx, cy = 0, 0, 0, 0, 0
lineStat = False
return lineStat, (cx, cy), a, b, c, boxL
def selectVerticalLines(self, whiteLines):
# 白線候補の縦線の中から2本選ぶ
vLines = []
for k in range(len(whiteLines)):
if whiteLines[k].direct == 0:# 縦線だけを抽出
vLines.append([whiteLines[k].lineLength, k])
if len(vLines) >= 2:# 縦線が2本以上あった場合、長い白線から2本選択
l0 = vLines.index(max(vLines))
vLines[l0][0] = 0
l1 = vLines.index(max(vLines))
k0, k1 = vLines[l0][1], vLines[l1][1]
xe0, xe1 = whiteLines[k0].lineC[1][0], whiteLines[k1].lineC[1][0]
if xe1 < xe0:
indexL, indexR = k1, k0
if xe0 < COL/2 and xe1 < COL/2:
indexL, indexR = k0, -1
elif xe0 > COL/2 and xe1 > COL/2:
indexL, indexR = -1, k1
else:
indexL, indexR = k0, k1
if xe0 < COL/2 and xe1 < COL/2:
indexL, indexR = k1, -1
elif xe0 > COL/2 and xe1 > COL/2:
indexL, indexR = -1, k0
lineStat = True
elif len(vLines) == 1: # 縦白線が1本しか検出できない時
k0 = vLines[0][1]
if whiteLines[k0].lineC[1][0] >= COL/2:
indexR, indexL = k0, -1
else:
indexR, indexL = -1, k0
lineStat = True
else: # 縦白線が認識されなかった時
lineStat, indexL, indexR = False, -1, -1
return lineStat, indexL, indexR
def detectWhiteLines(self):
# frameの中から、白線の輪郭を検出して、白線候補を検出。
imgray = cv2.cvtColor(self.frame, cv2.COLOR_BGR2GRAY) # 入力画像をグレースケール化
retVal, imgThresh = cv2.threshold(imgray, self.thresh, 255, 0) # グレースケール画像を閾値で抜き出し
imgThresh, contours, hierarchy = cv2.findContours(imgThresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
whiteLines=[]
for i in range(len(contours)):
if hierarchy[0][i][3] > 0: # hierarchyの親がなし(-1)、親parentが0の輪郭のみ抽出
continue
cnt = contours[i]
rectR = cv2.minAreaRect(cnt) # 回転を考慮した外接矩形 rectR : ((x,y), (w,h), angle)
box = cv2.boxPoints(rectR) # 4角のコーナーの座標
lineStat, (cx, cy), a, b, c, lineLen = self.isThisWhiteLine(cnt, box, rectR, LINE_MIN_LEN, LINE_MIN_RATIO, LINE_MAX_RATIO)
# 白線の場合lineStat = True、白線の中心線cy = a * x + b
if not lineStat: # 白線でない場合
continue
boxNP = np.int0(box) # 整数に
if ( abs(a) > 0.3 ): # 縦方向の白線
direct = 0
x0, y0, x1, y1 = int(-b / a), 0, int((c * ROW - b) / a), ROW
else: # 横方向の白線
direct = 1
x0, y0, x1, y1 = 0, int(b), COL, int(a * COL + b)
whiteLines.append(CONT_LINE(i, direct, [cx,cy], boxNP, [[x0,y0],[x1,y1]], lineLen))
return whiteLines
def rasPosition(self, frame):
self.frame = frame
whiteLines = self.detectWhiteLines() # 白線認識
lineStat, indexL, indexR = self.selectVerticalLines(whiteLines) #2本の白線を選択
if lineStat: # 1本 or 2本選択できた時
self.drawWhiteLines(whiteLines, indexL, indexR) # 左右白線の赤枠、センターラインなどを描画
posCol, xsL, xsR = self.calcCenterPos(whiteLines, indexL, indexR) # 白線センター位置の計算
else:
posCol = self.prevPosCol
xsL, xsR = self.prevL, self.prevR
cv2.circle(self.frame,(posCol, 20), 4, RED, -1) # 白線中央位置の描画
strData = "{:5d}".format(int(posCol - COL/2))
cv2.putText(self.frame, strData, (posCol, 50), CV_FONT, 0.5, GREEN, 1, cv2.LINE_AA, False)
self.prevPosCol = posCol
self.prevL = xsL
self.prevR = xsR
return posCol, self.frame
"""メイン関数"""
if __name__ == '__main__':
cap = cv2.VideoCapture(0)
iDetect = False
wLines = WHITE_LINES(thresh=160) # 白線検出の閾値=160
while(True):
ret, frame = cap.read() # WEBカメラからの画像をキャプチャして表示
if iDetect:
posCol, frame = wLines.rasPosition(frame) # 2本の白線を認識し、軌道のズレ量posCol(pixel)を返す
cv2.imshow('WEB CAMERA', frame) # 画像情報を描画
k = cv2.waitKey(1) & 0xFF
if k == ord('q'): # 終了
print("QUIT")
break
elif k == ord('d'): # ライン検出
iDetect = True if not iDetect else False
cap.release()
cv2.destroyAllWindows()
●2本の白線の中央に沿って自走するラズマウスの制御方法
ラズマウスの進行方向と、白線中央からのズレ量は、ラズマウスの車軸から前方\(400mm\)の位置で計測した値。速度\(v\)で前進するラズマウスが、\(400mm\)前方のズレ量\(\Delta d\)を\(0\)(ゼロ)になるように、左車輪と右車輪の速度差をそれぞれ、\(v(1\ -\ \alpha)、v(1\ +\ \alpha)\)とすることで旋回させる。
PID制御量を\(\Delta u\)とすると、\(\alpha = k \Delta u\)でコントロールすることになる。

\(r\theta\ =\ v(1\ -\ \alpha)\Delta t\) ・・・・・ (1)
\((r\ +\ W)\theta\ =\ v(1\ +\ \alpha)\Delta t\) ・・・・・(2)
(1)と(2)より
\(\displaystyle r\ =\ \frac{W}{2}(\frac{1}{\alpha}\ -\ 1)\)
\(\displaystyle \theta\ =\ \frac{2\alpha v\Delta t}{W}\)
\(\Delta d=20mm、L=400mmの時、\Delta t後に\Delta d=0\)とすると、
\(\displaystyle\theta=\tan^{-1}(\frac{20}{400})=0.05\)
\(\displaystyle\alpha = \frac{\theta}{2}\frac{W}{v\Delta t}\simeq \frac{0.05}{2}\frac{135}{400}= 0.008\)
\(\displaystyle\alpha\ =\ k\Delta u\) とすると、\(\displaystyle\Delta u=20mm\)の時、\(\displaystyle k=\frac{0.008}{20}=0.0004\)
左右車輪が指定速度になるまで時間ロスがあるので、感度を10倍に上げて、\(k=0.004\)とした。
<ラズマウスの定常速度>
ラズマウスの車輪径:\(\phi70mm\)
ステッピングモーター(SM-42BYG011)の1回転のステップ数:\(200steps\)
ドライバー(L6470)の定速前進速度:\(10,240=10 \times 1,024\)、分解能\(=0.015step/sec\)
走行速度\(v=70\pi \times 10,240 \times 0.015 \div 200 = 168.8mm/sec\)
\(\alpha:0~0.2\)
●白線認識ラズマウスの走行テスト
左右白線の間隔を約180mmとして、全長約3500mmのテストコースを設定。テストコースの前半は直線で、途中で右カーブしている。

●PID制御のログ
PID制御のパラメータは、Kp=0.5、Ki=0.5、Kd=0.08とした。以下のグラフは、0.12sec毎に記録した、軌道からのズレ量Δdと、PID制御量(u)、計算上の修正後のズレ予測量(d)、ラズマウスの走行軌跡を表している。まだパラメータのチューニングが必要だが、大きく蛇行せず自走している。
右カーブに入る時、ラズマウスの軌道が左側に大きくズレ、それを補正する制御が働き始めるが、ズレ量(Δd)を計測しているのは、前方400mmの位置なので、その位置に到達するまでに少しずつズレ量を補正している。

●画像認識した白線に沿って自走するラズマウス(YouTube)
ラズマウスに取り付けたWEBカメラでキャプチャした画像から白線を選択して、その左右白線の中線とラズマウスの走行方向のズレ量を最小にするようにPID制御して自走するラズマウス5号機。白線の認識と選択はOpenCVを使った。


