👌

定点カメラの映像から通過する電車の路線を画像認識してポケモン操作する

2022/03/02に公開約6,600字

はじめに

1・2年前に水槽に定点カメラつけて魚の位置でポケモンを操作するライブ配信がバズったのをご存じでしょうか?
今回はそれの鉄道版を作ろうと思います。
新宿駅近くにある鉄道のライブカメラの映像から画像認識で通過する電車の路線を識別して、山手線内回りならAボタン・中央線上りなら上ボタンなど路線ごとにボタンを割り振ってポケモンを操作していきます。
その様子は私のYouTubeチャンネルでもライブ配信しているので是非見てみてください。

https://youtu.be/JSbA9klGTv4

使った技術

  • Python
  • OpenCV
  • Selenium
  • Python-uinput

路線判別

定点カメラの映像をスクショする

SeleniumでChromeブラウザを操作し、定点カメラの映像ページを開きその映像を定期的にスクショして、その画像を使って通過する電車を識別していきます。

# ブラウザを非表示
options = Options()
options.add_argument('--headless')

chrome_service = fs.Service(executable_path=CHROMEDRIVER)
driver = webdriver.Chrome(service=chrome_service, options=options)

driver.set_window_size(1280, 580)
driver.get(url)
driver.save_screenshot(path)

画像を電車が識別しやすように加工

電車の識別は前のフレームの画像と現在のフレームの画像を比較してOpenCVで物体検出します。
さらに、変化した点を矩形でわかりやすく表現するために電車が水平になるように画像を回転しておきます。

img_now = cv2.imread(now_path)
img_before = cv2.imread(before_path)

img_now = img_now[200:500, 210:1060]
img_before = img_before[200:500, 210:1060]

result = cv2.absdiff(img_now, img_before)
result_gray = cv2.cvtColor(result, cv2.COLOR_BGR2GRAY)
result_gray = cv2.GaussianBlur(result_gray,(7,7),0)

# 二値化
_, result_bin = cv2.threshold(result_gray, 5, 255, cv2.THRESH_BINARY)

# 回転
center = tuple(np.array([result_bin.shape[1] * 0.5, result_bin.shape[0] * 0.5]))
size = tuple(np.array([result_bin.shape[1], result_bin.shape[0]]))
rotation_matrix = cv2.getRotationMatrix2D(center, -10, 1.0)

# アフィン変換
result_bin = cv2.warpAffine(result_bin, rotation_matrix, size, flags=cv2.INTER_CUBIC)

# ノイズ除去
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (30, 10))
result_bin = cv2.morphologyEx(result_bin, cv2.MORPH_OPEN, kernel)

電車の位置を決める

電車の画像認識
これはもう手動で山手線の内回りはx座標が0のときy座標がいくつからいくつまでの間に電車がくるというのをきめていきます。
そして電車はよくマンガとかで言われるパース的なあれですべて電車は一点にぶつかるはずです。(たぶん)
その点をcross_pointとしてこれも手動で求めて導きます。

cross_point = (1077, 90)
line1_up = (0, 3)
line1_down = (0, 40)
line2_up = (0, 15)
line2_down = (0, 60)
line3_up = (0, 60)
line3_down = (0, 140)
line4_up = (0, 85)
line4_down = (0, 180)
line5_up = (0, 10)
line5_down = (0, 50)
line6_up = (0, 45)
line6_down = (0, 110)
line7_up = (0, 120)
line7_down = (0, 250)
line8_up = (0, 180)
line8_down = (0, 350)

# from left
line1 = np.array((line1_up, line1_down, cross_point)) # Saikyo Line
line2 = np.array((line2_up, line2_down, cross_point)) # Chuo Line
line3 = np.array((line3_up, line3_down, cross_point)) # Sobu Line
line4 = np.array((line4_up, line4_down, cross_point)) # Yamanote Line
#from right
line5 = np.array((line5_up, line5_down, cross_point)) # Saikyo Line
line6 = np.array((line6_up, line6_down, cross_point)) # Chuo Line
line7 = np.array((line7_up, line7_down, cross_point)) # Yamanote Line
line8 = np.array((line8_up, line8_down, cross_point)) # Sobu Line

line_names = ['Saikyo Line From Left', 'Chuo Line From Left', 'Sobu Line From Left', 'Yamonote Line From Left', 'Saikyo Line From Right', 'Chuo Line From Right', 'Yamanote Line From Right', 'Sobu Line From Right']
lines = [line1, line2, line3, line4, line5, line6, line7, line8]

電車が右から左に動いているか、左から右に動いているか

前のフレームの画像と現在のフレームの差分が画像の右にあるのか左にあるのかをみて大雑把な電車の位置とそれが右から来ているのか、左から来ているのかを判定します。

img_right = result_bin[:, :width]
img_left = result_bin[:, width:width*2]
for i in range(len(lines)):
	b1 = lines[i][0][1]
        a1 = -(b1-lines[i][2][1])/lines[i][2][0]
        y1 = width*a1+b1
        b2 = lines[i][1][1]
        a2 = -(b2-lines[i][2][1])/lines[i][2][0]
        y2 = width*a2+b2
        for j in range(int(width/50)):
            left_pt1, left_pt2 = get_points(a1, b1, a2, b2, j)
            right_pt1, right_pt2 = get_points(a1, y1, a2, y2, j)
	    left_range = left_img[int(left_plt1[1]):int(left_plt2[1]), int(left_plt1[0]):int(left_plt2[0])]
	    left_whole_area=left_check_range.size
	    left_white_area=cv2.countNonZero(left_check_range)
	    right_range = right_img[int(right_plt1[1]):int(right_plt2[1]), int(right_plt1[0]):int(right_plt2[0])]
	    right_whole_area=right_check_range.size
	    right_white_area=cv2.countNonZero(right_check_range)
	    # left_scoreが大きければlines[i]は左側にいっぱい差分がある
	    left_score = left_white_area/left_whole_area
	    # right_scoreが大きければlines[i]は右側にいっぱい差分がある
	    right_score = right_white_area/right_whole_area

このleft_scoreとright_scoreを使って何線のどっち行きの電車が来ているかというの判定します。

時間をかけて正確な判別を行う

何線のどっち行きの電車かというの判定しましたが、このままではまだ精度があまり良くありません。
そこでさらに精度を上げるために電車が大きく映る画像の左側で何線かもう一度判定します。
さらに数フレームの差分を見て正確な判定を行います。

ポケモンを操作

ポケモンの操作はPython-uinputで仮想ジョイパッドを使って行っていきます。

ポケモンで使うボタンは主に上・下・右・左・A・Bです。StartとSelectも使うこともありますが、頻度が低いので進行上必要な際には人力で押すことにします。

細かい操作も大胆な操作も可能にするためにボタンを入力開始する時間の分数0.1秒間ボタンを連打する(例えば13:20分なら2秒間連打、20:50分なら5秒間連打)

def readInput(path):
    commands = []
    with open(path, mode='r') as f:
        for line in f:
            commands.append(line.rstrip('\n').split(',')[0])
    with open(path, mode='w') as f:
        f.write('')
    return commands

def get_input(command):
    if command == 'A':
        return [uinput.ABS_HAT1Y, -30000]
    elif command == 'B':
        return [uinput.ABS_HAT1Y, 30000]
    elif command == 'UP':
        return [uinput.ABS_HAT0Y, -30000]
    elif command == 'DOWN':
        return [uinput.ABS_HAT0Y, 30000]
    elif command == 'LEFT':
        return [uinput.ABS_HAT0X, -30000]
    elif command == 'RIGHT':
        return [uinput.ABS_HAT0X, 30000]
    print('Error:', command)

def push_button(device, command):
    btn = get_input(command)
    device.emit(btn[0], btn[1])
    time.sleep(0.1)
    device.emit(btn[0], 0)
    time.sleep(0.1)
    
def main():
    events = (uinput.BTN_JOYSTICK,
              uinput.ABS_HAT0Y,
              uinput.ABS_HAT0X,
              uinput.ABS_HAT1Y,
              uinput.ABS_HAT1X
    )
    # 多い順: 山手貨物線南行 山手貨物線北行 中央線下り 山手線外回り 山手線内回り 中央線上り 総武・中央線東行 総武・中央線西行                                
    lines = [['UP', '山手貨物線南行'], ['DOWN', '中央線上り'], ['LEFT', '中央・総武緩線東行'], ['RIGHT', '山手線内回り'], ['A', '山手貨物線北行'], ['UP', '\\
中央線下り'], ['A', '山手線外回り'], ['B', '中央・総武線緩西行']]
    push_count = ['', -1]
    with uinput.Device(events) as device:
	line = lines[int(command)]
	push_count = [line[0], int(datetime.datetime.now().minute/2)]
	push_button(device, line[0])
    if push_count[1] > 0:
	push_button(device, push_count[0])
	push_count[1] -= 1

最後に

エミュレーターを使う際はROMは自分で吸い出してください
レイレイトレインのYouTubeチャンネルでクリアまでをライブ配信していこうと思っているので是非チャンネル登録お願いします。

Discussion

ログインするとコメントできます