Udacity机器人软件工程师课程笔记(五)-样本搜索和找回-基于漫游者号模拟器-自主驾驶
9.自主駕駛
在接下來的環節中,我們要實現漫游者號的自動駕駛功能。
完成這個功能我們需要四個程序,第一個為感知程序,其對攝像頭輸入的圖片進行變換處理和坐標變換使用。第二個程序為決策程序,功能是幫助漫游者號根據當前條件和狀態進行相應的決策,以實現漫游者號前進,后退,轉向等功能。第三個是支持程序,來定義一些關于漫游者號狀態的類等。最后為主程序,來調用三個函數對漫游者號進行控制的。
Udacity提供的初始程序在Udacity機器人軟件工程師課程筆記(三)中有所提供。
經過前面的學習,我們已經清楚了感知程序和決策程序,因為之前我都把它寫成了一個程序,這樣導致程序顯得非常復雜,可讀性不是太高。現在針對各個功能程序進行程序的劃分,來方便最后的主程序的調用。以下的初始程序都放在RoboND-Rover-Project文件夾下的code文件夾中。
(1) 感知部分perception.py
首先是感知部分。感知部分perception.py應當具有三個功能,
- 圖像閾值處理
- 透視變換
- 坐標變換
這些功能在之前的程序中已經多次使用了,但是我們需要調用這些功能去編寫一個綜合感知函數 perception_step() 來對圖像進行處理,它需要完成以下功能:
1)定義透視變換的源點和目標點
2)應用透視變換
3)應用顏色閾值來識別可導航的地形/障礙物/巖石樣本
4)更新Rover.vision_image(這個圖像顯示在屏幕的左側)
5)將地圖圖像像素值轉換為以漫游者號為中心的坐標
6)將以漫游者號為中心的像素值坐標轉換為世界坐標
7)更新漫游者號的世界地圖(這個圖像顯示在屏幕右側)
8)將以漫游者號為中心的像素位置轉換為極坐標
感知部分的程序如下
import numpy as np
import cv2
import matplotlib.pyplot as plt##圖像處理
# 定義二值化圖像函數
def color_thresh(img, rgb_thresh=(160, 160, 160)):img_thresh = np.zeros_like(img[:, :, 0])above_thresh = (img[:, :, 0] > rgb_thresh[0]) \& (img[:, :, 1] > rgb_thresh[1]) \& (img[:, :, 2] > rgb_thresh[2])img_thresh[above_thresh] = 255return img_thresh##透視變換
# 定義圖像映射函數,將攝像頭的圖像映射到平面坐標中去
def perspect_transform(img, src, dst):M = cv2.getPerspectiveTransform(src, dst) # 定義變換矩陣img_perspect = cv2.warpPerspective(img, M, (img.shape[1], img.shape[0]))return img_perspect##坐標變換
# 定義從圖像坐標轉換成漫游者號坐標函數
def rover_coords(binary_img):ypos, xpos = binary_img.nonzero()x_pixel = -(ypos - binary_img.shape[0]).astype(np.float)y_pixel = -(xpos - binary_img.shape[1]/2 ).astype(np.float)return x_pixel, y_pixel# 定義旋轉操作函數
def rotate_pix(xpix, ypix, yaw):yaw_rad = yaw * np.pi / 180xpix_rotated = (xpix * np.cos(yaw_rad)) - (ypix * np.sin(yaw_rad))ypix_rotated = (xpix * np.sin(yaw_rad)) + (ypix * np.cos(yaw_rad))return xpix_rotated, ypix_rotated# 定義平移操作函數
def translate_pix(xpix_rot, ypix_rot, xpos, ypos, scale):xpix_translated = (xpix_rot / scale) + xposypix_translated = (ypix_rot / scale) + yposreturn xpix_translated, ypix_translated# 定義綜合函數,將旋轉和平移函數進行結合,并限制了圖像范圍
def pix_to_world(xpix, ypix, xpos, ypos, yaw, world_size, scale):xpix_rot, ypix_rot = rotate_pix(xpix, ypix, yaw)xpix_tran, ypix_tran = translate_pix(xpix_rot, ypix_rot, xpos, ypos, scale)x_pix_world = np.clip(np.int_(xpix_tran), 0, world_size - 1)y_pix_world = np.clip(np.int_(ypix_tran), 0, world_size - 1)return x_pix_world, y_pix_world# 定義轉換為極坐標函數
def to_polar_coords(xpix, ypix):dist = np.sqrt(xpix**2 + ypix ** 2)angles = np.arctan2(ypix, xpix)return dist, anglesdef perception_step(Rover):# (1)定義透視變換的原點和目標點# 參考圖像filename = 'E:/2019/RoboND-Rover-Project-master/calibration_images/example_grid1.jpg'image = cv2.imread(filename)dst_size = 5bottom_offset = 0src = np.float32([[14, 140], [301, 140], [200, 96], [118, 96]])dst = np.float32([[image.shape[1] / 2 - dst_size, image.shape[0] - bottom_offset],[image.shape[1] / 2 + dst_size, image.shape[0] - bottom_offset],[image.shape[1] / 2 + dst_size, image.shape[0] - 2 * dst_size - bottom_offset],[image.shape[1] / 2 - dst_size, image.shape[0] - 2 * dst_size - bottom_offset],])# (2)應用透視變換img_perspect = perspect_transform(Rover.img, src, dst)# (3)應用顏色閾值來識別可導航的地形/障礙物/巖石樣本img_thresh_ter = color_thresh(img_perspect, rgb_thresh=(160, 160, 160))img_thresh_obs = color_thresh(img_perspect, rgb_thresh=(160, 160, 160))img_thresh_roc = color_thresh(img_perspect, rgb_thresh=(160, 130, 0))# (4)更新ROver.vision_imageRover.vision_image[:, :, 0] = img_thresh_rocRover.vision_image[:, :, 1] = img_thresh_obsRover.vision_image[:, :, 2] = img_thresh_ter# (5)將地圖像素值轉換為以漫游者號為中心的坐標xpix, ypix = rover_coords(img_thresh_ter)# (6)將以漫游者號為中心的像素值坐標轉化為世界地圖坐標x_pix_world, y_pix_world = pix_to_world(xpix, ypix, xpos=Rover.pos[0], ypos=Rover.pos[1], yaw=Rover.yaw, world_size=200, scale=10)# (7)更新漫游者號的世界地圖Rover.worldmap[x_pix_world, y_pix_world] += 1# (8)將以漫游者號為中心的像素位置轉換為極坐標rover_distances, rover_angles = to_polar_coords(xpix, ypix)# 可導航地形像素的角度的數組Rover.nav_angles = rover_angles# 獲取轉向角,使用極坐標系角度的平均值,這樣可以運行,但是會有意外情況avg_angle_degrees = np.mean((rover_angles ) * 180 / np.pi)Rover.steer= np.clip(avg_angle_degrees, -15, 15)# 獲取油門速度,使用平均極坐標系的平均值Rover.throttle = np.mean(rover_distances)print('steer:', Rover.steer, 'Rover.throttle:', Rover.throttle)return Rover
(2) 決策部分decision.py
使用決策樹邏輯編寫的簡單判別系統,控制漫游者號對目前的狀態和環境做出相應的判斷,并執行相應的加速,轉向,停止等操作。
import numpy as np# 命令基于perception_step()函數的輸出
def decision_step(Rover):if Rover.nav_angles is not None:# 檢查 Rover.mode 狀態if Rover.mode == 'forward':# 檢查可導航地形的范圍if len(Rover.nav_angles) >= Rover.stop_forward: # 如果為forward模式,可導航地圖是好的,速度如果低于最大值,則加速if Rover.vel < Rover.max_vel:# 設置油門值Rover.throttle = Rover.throttle_setelse:Rover.throttle = 0Rover.brake = 0# 將轉向設置為平均夾角,范圍為 +/- 15Rover.steer = np.clip(np.mean(Rover.nav_angles * 180/np.pi), -15, 15)# 如果缺少地形圖,則設置為停止模式elif len(Rover.nav_angles) < Rover.stop_forward:# 設置到停止模式并剎車Rover.throttle = 0# 設置制動值Rover.brake = Rover.brake_setRover.steer = 0Rover.mode = 'stop'# 如果我們已經處于“停止”模式,那么做出不同的決定elif Rover.mode == 'stop':# 如果我們處于停止模式但仍然繼續制動if Rover.vel > 0.2:Rover.throttle = 0Rover.brake = Rover.brake_setRover.steer = 0# 如果我們沒有移動(vel <0.2),那就做點別的elif Rover.vel <= 0.2:# 現在為停止狀態,依靠視覺數據,判斷是否有前進的道路if len(Rover.nav_angles) < Rover.go_forward:Rover.throttle = 0# 松開制動器以便轉動Rover.brake = 0# 轉彎范圍為+/- 15度,當停止時,下一條線將引起4輪轉向Rover.steer = -15#如果停下來但看到前面有足夠的可導航地形,那就啟動if len(Rover.nav_angles) >= Rover.go_forward:# 將油門設置回存儲值Rover.throttle = Rover.throttle_setRover.brake = 0# 將轉向設置為平均角度Rover.steer = np.clip(np.mean(Rover.nav_angles * 180/np.pi), -15, 15)Rover.mode = 'forward'else:Rover.throttle = Rover.throttle_setRover.steer = 0Rover.brake = 0# 在可拾取巖石的狀態下發送拾取命if Rover.near_sample and Rover.vel == 0 and not Rover.picking_up:Rover.send_pickup = Truereturn Rover
(3)項目支持函數
提供一些支持項目運行的函數,比如創建輸出圖像等等,具體的功能可以看程序。
程序如下:
import numpy as np
import cv2
from PIL import Image
from io import BytesIO, StringIO
import base64
import time# 定義一個函數,將遙測字符串轉換為浮點數,與十進制約定無關
def convert_to_float(string_to_convert):if ',' in string_to_convert:float_value = np.float(string_to_convert.replace(',', '.'))else:float_value = np.float(string_to_convert)return float_valuedef update_rover(Rover, data):# 初始化開始時間和樣本位置if Rover.start_time is None:Rover.start_time = time.time()Rover.total_time = 0samples_xpos = np.int_([convert_to_float(pos.strip()) for pos in data["samples_x"].split(';')])samples_ypos = np.int_([convert_to_float(pos.strip()) for pos in data["samples_y"].split(';')])Rover.samples_pos = (samples_xpos, samples_ypos)Rover.samples_to_find = np.int(data["sample_count"])# 或者只是更新已用時間eelse:tot_time = time.time() - Rover.start_timeif np.isfinite(tot_time):Rover.total_time = tot_time# 打印遙測數據字典中的字段print(data.keys())# 漫游者號的當前速度,單位為m / sRover.vel = convert_to_float(data["speed"])# 漫游者號的當前位置Rover.pos = [convert_to_float(pos.strip()) for pos in data["position"].split(';')]# 當前的漫游者號偏航角Rover.yaw = convert_to_float(data["yaw"])# 當前的漫游者號的傾斜度Rover.pitch = convert_to_float(data["pitch"])# 當前的漫游者號轉動角度Rover.roll = convert_to_float(data["roll"])# 當前油門設置Rover.throttle = convert_to_float(data["throttle"])# 當前轉向角Rover.steer = convert_to_float(data["steering_angle"])# Near sample 標志Rover.near_sample = np.int(data["near_sample"])# Picking up 標志Rover.picking_up = np.int(data["picking_up"])# 更新收集的巖石數量Rover.samples_collected = Rover.samples_to_find - np.int(data["sample_count"])print('speed =', Rover.vel, 'position =', Rover.pos, 'throttle =',Rover.throttle, 'steer_angle =', Rover.steer, 'near_sample:', Rover.near_sample,'picking_up:', data["picking_up"], 'sending pickup:', Rover.send_pickup,'total time:', Rover.total_time, 'samples remaining:', data["sample_count"],'samples collected:', Rover.samples_collected)# 從漫游者號的中央攝像頭獲取當前圖像imgString = data["image"]image = Image.open(BytesIO(base64.b64decode(imgString)))Rover.img = np.asarray(image)# 返回更新的漫游者號和單獨的圖像以進行可選保存return Rover, image# 定義一個函數,根據世界地圖結果創建顯示輸出
def create_output_images(Rover):# 創建一個縮放的地圖,用于繪制和清理一下障礙物/導航像素if np.max(Rover.worldmap[:, :, 2]) > 0:nav_pix = Rover.worldmap[:, :, 2] > 0navigable = Rover.worldmap[:, :, 2] * (255 / np.mean(Rover.worldmap[nav_pix, 2]))else:navigable = Rover.worldmap[:, :, 2]if np.max(Rover.worldmap[:, :, 0]) > 0:obs_pix = Rover.worldmap[:, :, 0] > 0obstacle = Rover.worldmap[:, :, 0] * (255 / np.mean(Rover.worldmap[obs_pix, 0]))else:obstacle = Rover.worldmap[:, :, 0]likely_nav = navigable >= obstacleobstacle[likely_nav] = 0plotmap = np.zeros_like(Rover.worldmap)plotmap[:, :, 0] = obstacleplotmap[:, :, 2] = navigableplotmap = plotmap.clip(0, 255)# 覆蓋障礙物和可導航的地形圖與地面實況圖map_add = cv2.addWeighted(plotmap, 1, Rover.ground_truth, 0.5, 0)# 檢查worldmap中是否存在任何巖石rock_world_pos = Rover.worldmap[:, :, 1].nonzero()# 如果有,我們將逐步完成已知的樣品位置# 確認檢測是否真實samples_located = 0if rock_world_pos[0].any():rock_size = 2for idx in range(len(Rover.samples_pos[0])):test_rock_x = Rover.samples_pos[0][idx]test_rock_y = Rover.samples_pos[1][idx]rock_sample_dists = np.sqrt((test_rock_x - rock_world_pos[1]) ** 2 + \(test_rock_y - rock_world_pos[0]) ** 2)# 如果在已知樣品位置3米范圍內檢測到巖石,# 則認為它成功并在地圖上繪制已知樣品的位置if np.min(rock_sample_dists) < 3:samples_located += 1map_add[test_rock_y - rock_size:test_rock_y + rock_size,test_rock_x - rock_size:test_rock_x + rock_size, :] = 255# 計算地圖結果的一些統計數據# 首先獲取可導航地形圖中的總像素數tot_nav_pix = np.float(len((plotmap[:, :, 2].nonzero()[0])))# 接下來計算出其中有多少對應于地面實況像素good_nav_pix = np.float(len(((plotmap[:, :, 2] > 0) & (Rover.ground_truth[:, :, 1] > 0)).nonzero()[0]))# 接下來找出有多少不對應地面實況像素bad_nav_pix = np.float(len(((plotmap[:, :, 2] > 0) & (Rover.ground_truth[:, :, 1] == 0)).nonzero()[0]))# 抓取地圖像素的總數tot_map_pix = np.float(len((Rover.ground_truth[:, :, 1].nonzero()[0])))# 計算已成功找到的地面實況圖的百分比perc_mapped = round(100 * good_nav_pix / tot_map_pix, 1)# 計算好的地圖像素檢測數除以總像素數# 發現是可導航的地形if tot_nav_pix > 0:fidelity = round(100 * good_nav_pix / (tot_nav_pix), 1)else:fidelity = 0# 翻轉地圖進行繪圖,使y軸在顯示屏中指向上方map_add = np.flipud(map_add).astype(np.float32)# 添加一些關于地圖和巖石樣本檢測結果的文字cv2.putText(map_add, "Time: " + str(np.round(Rover.total_time, 1)) + ' s', (0, 10),cv2.FONT_HERSHEY_COMPLEX, 0.4, (255, 255, 255), 1)cv2.putText(map_add, "Mapped: " + str(perc_mapped) + '%', (0, 25),cv2.FONT_HERSHEY_COMPLEX, 0.4, (255, 255, 255), 1)cv2.putText(map_add, "Fidelity: " + str(fidelity) + '%', (0, 40),cv2.FONT_HERSHEY_COMPLEX, 0.4, (255, 255, 255), 1)cv2.putText(map_add, "Rocks", (0, 55),cv2.FONT_HERSHEY_COMPLEX, 0.4, (255, 255, 255), 1)cv2.putText(map_add, " Located: " + str(samples_located), (0, 70),cv2.FONT_HERSHEY_COMPLEX, 0.4, (255, 255, 255), 1)cv2.putText(map_add, " Collected: " + str(Rover.samples_collected), (0, 85),cv2.FONT_HERSHEY_COMPLEX, 0.4, (255, 255, 255), 1)# 將地圖和視覺圖像轉換為base64字符串以便發送到服務器pil_img = Image.fromarray(map_add.astype(np.uint8))buff = BytesIO()pil_img.save(buff, format="JPEG")encoded_string1 = base64.b64encode(buff.getvalue()).decode("utf-8")pil_img = Image.fromarray(Rover.vision_image.astype(np.uint8))buff = BytesIO()pil_img.save(buff, format="JPEG")encoded_string2 = base64.b64encode(buff.getvalue()).decode("utf-8")return encoded_string1, encoded_string2
(4)主函數drive_rover.py
控制漫游者號運行的主程序,調用前面三個子程序完成漫游者號的自動駕駛功能。
其中有一些關于socketio服務器和Flask應用程序的語句,這里不詳細說明了,可能的話我以后會單獨寫一篇文章來單獨說明。如果有想要詳細的,可以看這里。
其程序如下:
import argparse
import shutil
import base64
from datetime import datetime
import os
import cv2
import numpy as np
import socketio
import eventlet
import eventlet.wsgi
from PIL import Image
from flask import Flask
from io import BytesIO, StringIO
import json
import pickle
import matplotlib.image as mpimg
import time# Import functions for perception and decision making
from perception import perception_step
from decision import decision_step
from supporting_functions import update_rover, create_output_images
# 初始化socketio服務器和Flask應用程序
# (learn more at: https://python-socketio.readthedocs.io/en/latest/)
sio = socketio.Server()
app = Flask(__name__)# 讀取地面實況圖并創建綠色通道的圖像
# 注意:圖像默認以左上角為原點(0,0)y軸向下讀取圖片,要求圖片為單層
ground_truth = mpimg.imread('E:/2019/RoboND-Rover-Project-master/calibration_images/map_bw.png')
ground_truth = ground_truth[:,:,0] # 我的圖片維度出了一些問題
# 下一行在紅色和藍色通道中創建零數組,并將地圖放入綠色通道。
# 這就是地圖輸出在顯示圖像中顯示為綠色的原因
ground_truth_3d = np.dstack((ground_truth*0, ground_truth*255, ground_truth*0)).astype(np.float)# 定義RoverState()類
class RoverState():def __init__(self):self.start_time = None # 記錄導航開始的時間self.total_time = None # 記錄導航的總持續時間self.img = None # 當前的相機圖像self.pos = None # 當前的位置 (x, y)self.yaw = None # 當前的偏航角self.pitch = None # 當前的俯仰角self.roll = None # 當前的旋轉角self.vel = None # 當前的速度self.steer = 0 # 當前的轉向角self.throttle = 0 # 當前的油門值self.brake = 0 # 當前的制動值self.nav_angles = None # 可導航地形像素的角度self.nav_dists = None # 可導航地形像素的距離self.ground_truth = ground_truth_3d # 真實的世界地圖self.mode = 'forward' # 當前模式 (可以是前進或者停止)self.throttle_set = 0.2 # 加速時的節流設定self.brake_set = 10 # 剎車時的剎車設定# 下面的stop_forward和go_forward字段表示可導航地形像素的總數。# 這是一種非常粗糙的形式來表示漫游者號當何時可以繼續前進或者何時應該停止。# 可以隨意添加新字段或修改這些字段。self.stop_forward = 50 # T啟動停止時的閾值self.go_forward = 500 # 再次前進的閾值self.max_vel = 2 # 最大速度 (m/s)# 感知步驟的圖像輸入# 更新此圖像以顯示中間分析步驟# 在自主模式下的屏幕上self.vision_image = np.zeros((160, 320, 3), dtype=np.float)# 世界地圖# 使用可導航的位置更新此圖像# 障礙物和巖石樣本self.worldmap = np.zeros((200, 200, 3), dtype=np.float)self.samples_pos = None # 存儲實際樣本的位置self.samples_to_find = 0 # 儲存樣本的初始計數self.samples_located = 0 # 儲存位于地圖上的樣本數self.samples_collected = 0 # 儲存收集的樣本數self.near_sample = 0 # 設置附近樣本數為0self.picking_up = 0 # 設置["picking_up"]值self.send_pickup = False# 實例化類
Rover = RoverState()# 用于跟蹤每秒幀數的變量(FPS)
# 初始化幀計數器
frame_counter = 0
# 設置幀計數器
second_counter = time.time()
fps = None# 定義遙測功能
@sio.on('telemetry')
def telemetry(sid, data):global frame_counter, second_counter, fpsframe_counter += 1# 粗略計算每秒幀數(FPS)if (time.time() - second_counter) > 1:fps = frame_counterframe_counter = 0second_counter = time.time()print("Current FPS: {}".format(fps))if data:global Rover# 使用當前遙測技術初始化/更新漫游者號Rover, image = update_rover(Rover, data)if np.isfinite(Rover.vel):# 執行感知和決策步驟以更新漫游者號的狀態Rover = perception_step(Rover)Rover = decision_step(Rover)# 創建輸出圖像以發送到服務器out_image_string1, out_image_string2 = create_output_images(Rover)# 行動步驟!,發送命令到漫游者號# 不要同時發送這兩個,它們都會觸發模擬器發送回新的遙測數據,# 因此我們只能將其中一個發送回當前的遙測數據。# 如果處于可拾取巖石的狀態發送拾取命令if Rover.send_pickup and not Rover.picking_up:send_pickup()Rover.send_pickup = Falseelse:# 發送命令到漫游者號commands = (Rover.throttle, Rover.brake, Rover.steer)send_control(commands, out_image_string1, out_image_string2)# 如果遙測無效,請發送空命令else:# 發送零點用于油門,制動和轉向以及清空圖像send_control((0, 0, 0), '', '')# 如果要從自動駕駛中保存攝像機圖像,請指定路徑# Example: $ python drive_rover.py image_folder_path# 如果指定了文件夾,則可以保存圖像幀if args.image_folder != '':timestamp = datetime.utcnow().strftime('%Y_%m_%d_%H_%M_%S_%f')[:-3]image_filename = os.path.join(args.image_folder, timestamp)image.save('{}.jpg'.format(image_filename))else:sio.emit('manual', data={}, skip_sid=True)@sio.on('connect')
def connect(sid, environ):print("connect ", sid)send_control((0, 0, 0), '', '')sample_data = {}sio.emit("get_samples",sample_data,skip_sid=True)def send_control(commands, image_string1, image_string2):# 定義要發送到漫游者號的命令data = {'throttle': commands[0].__str__(),'brake': commands[1].__str__(),'steering_angle': commands[2].__str__(),'inset_image1': image_string1,'inset_image2': image_string2,}# 通過socketIO服務器發送命令sio.emit("data",data,skip_sid=True)eventlet.sleep(0)# 定義一個發送“拾取”命令的功能
def send_pickup():print("Picking up")pickup = {}sio.emit("pickup",pickup,skip_sid=True)eventlet.sleep(0)if __name__ == '__main__':parser = argparse.ArgumentParser(description='Remote Driving')parser.add_argument('image_folder',type=str,nargs='?',default='',help='Path to image folder. This is where the images from the run will be saved.')args = parser.parse_args()# os.system('rm -rf IMG_stream/*')if args.image_folder != '':print("Creating image folder at {}".format(args.image_folder))if not os.path.exists(args.image_folder):os.makedirs(args.image_folder)else:shutil.rmtree(args.image_folder)os.makedirs(args.image_folder)print("Recording this run ...")else:print("NOT recording this run ...")# 用socketio的中間件包裝Flask應用程序app = socketio.Middleware(sio, app)# 部署為eventlet WSGI服務器eventlet.wsgi.server(eventlet.listen(('', 4567)), app)
其輸出如下:
gif演示:
因為圖片大小限制的原因,gif的質量比較差。
但是我們可以看出,程序已經可以基本正常運行了。但是我們的自主駕駛還有很多問題,比如當我們正對著巖石障礙時,漫游者號會選擇直沖向巖石。這是由我們取平均值的算法決定的,但是這完全是不可行的。
而且漫游者號現在還處于一個隨機駕駛狀態,它只能在地圖中進行隨機的駕駛,無法遍歷整個地圖。這也是我們的程序需要升級的地方。
在下個筆記中,要實現漫游者號對地圖進行穩定的遍歷,而且要對所有巖石樣本進行采集。然后我們才算完成了這個漫游者號火星車項目的所有部分。
總結
以上是生活随笔為你收集整理的Udacity机器人软件工程师课程笔记(五)-样本搜索和找回-基于漫游者号模拟器-自主驾驶的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Udacity机器人软件工程师课程笔记(
- 下一篇: Udacity机器人软件工程师课程笔记(