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

TopPhoto
白線を認識して自動走行するラズマウス

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

●白線を認識するラズマウスの構成部品

ラズマウス全景
ラズマウスの構成

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

WEBカメラの撮影範囲
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の位置なので、その位置に到達するまでに少しずつズレ量を補正している。

PID制御ログ
PID制御ログ

●画像認識した白線に沿って自走するラズマウス(YouTube)

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

白線を認識して自動走行するラズマウス