# OpenCV 循跡車演算法 傳統做法使用紅外線感測器,利用不同材質反射的紅外線多寡判斷材質。但是這樣有以下缺點: 1. 雜訊影響大:在實際的測試中,需要距離地面約 1 cm 距離,才能有較精準的感應。若車子在運動過程中震動,該因素將難以消除。 2. 資訊有限:僅能取得數個資料點,必須針對少數幾個點建立控制計畫,局限性大。 3. 演算法設計難度高:在僅能知道數個點的資訊之下,實作演算法 另外,Arduino 雖然是能夠迅速上手的單片機,但是仍然缺乏一個現代作業系統有的各種優良特色:排程、多執行緒、平行運算等等。因此僅能做出非常有限的控制。 現在是二十一世紀,我們已經有非常發達的作業系統,摩爾定律也讓我們有重量與效能兼具的處理器。我們應該問問:在處理器上裝個作業系統,能不能為我們帶來什麼好處? 影像辨識: 1. 一般性的解決方案:不用調感測器敏感度調到升天,只需要關注在該用什麼樣的演算法從影像中抽取需要的資訊。 2. 功能擴展性佳:若需要擴充功能,僅需在演算法上面修改,配上多執行緒的程式技巧,使不同的執行緒負責不同功能,硬體方面並不需要做太多更動。舉例來說,若希望增加避障功能,僅需增加「分辨前景、後景」的演算即,即可偵測障礙物。這是紅外線感測器難以匹敵的。 3. 彈性高:若紅外線感應器需要更高解析度,則需要安裝更多硬體。然而,使用影像辨識的方式,僅需改變程式中的一個數字,便可做到兩倍紅外線感測器才能做到的事,且解析度更高,辨識更準確。 4. 價格低廉:以本次實驗中的攝影機為例,僅新台幣 70 元,價格接近 一顆 TCRT5000 紅外線模組。 本次氣動車計畫中,計劃使用 Raspberry Pi 搭配 Respbian 作業系統,作為影像處理的核心,並且使用 OpenCV 作為處理影像的函式庫,搭配 Arduino 作為 I/O slave ## 架構 ![](https://i.imgur.com/FMEuMuI.jpg) ## 影像處理 ![](https://i.imgur.com/xe89fKU.jpg) 程式處理之流程如下: 1. 將圖片沿 y 方向,分割成 N 等分。 2. 對於每一張分割的圖片,尋找顏色最深的部分,並且找出包住該深色部分的輪廓,並尋找該輪廓的質心。 3. 對於每一張圖,找到該質心後,檢測所有線段質心圍成的凸包(convex hull)面積。若面積太大,表示該資料受到其他陰影干擾,予以捨棄。 4. 使用以下的方式計算伺服馬達轉動角度: $$\phi = \left(\left(1 - \frac {|x|}{W/2}\right)\theta + \left(\frac {|x|}{W/2}\right)\frac {x}{W/2} \cdot \pi\right)$$ 6. 將該角度存入 PID 控制器 7. 由 PID 控制器計算出轉動角度 8. 轉成伺服馬達對應的 PWM,並且輸出。 ## 辨識黑線 攝影機取到的影像資料為 BGR 表示,若需要偵測黑線,則必須同時對藍、綠、紅等像素的數值大小進行限制,程式上較麻煩。因此,這裡把影像的表示,從 BGR 三色,轉換成「彩度(Hue) - 飽和度(Saturation)- 亮度(Value)」表示法,即 HSV 影像空間。各種顏色對應之 HSV 可以繪製出如下的柱狀圖: ![](https://i.imgur.com/AViwOZO.png) (來源:https://zh.wikipedia.org/wiki/HSL和HSV色彩空间) 由該圖可以發現:若需要辨識深色,僅需尋找 Value 低的像素,而不需要針對單一像素的 3 個變數都進行限制。如此一來,便可將 BGR 需要設立的 3 個條件,轉為 HSV 空間中的單一條件: ```python= for i in range(0, slice_num) : ... sliced_img = img[X_DIV*i + 1:X_DIV*(i+1), int(0):int(IMG_WIDTH)] imgHSV = cv2.cvtColor(blur, cv2.COLOR_BGR2HSV) mask = cv2.inRange(imgHSV, LB, UB) ... ``` ## 雜訊過濾 ### Erosion & Dilation Erosion 可以想像成用一個黑色的筆刷,沿著所有黑線邊緣刷過一遍。 ![](https://i.imgur.com/ouR945J.png) 白色部分看起像是被黑色部分「腐蝕」。 Dilation 則是相反的運算: ![](https://i.imgur.com/AlSDGiI.png) 若進行 erosion,接著進行 dilation,則對於大面積的白色圖樣,其大小大致維持不變; 但對於較細小雜訊,該雜訊將會在 erision 的過程中被消除。將 Erosion 與 Dilation 接連使用的動作,稱作 Open 與 Close。 * Open : 先進行 Erision,再進行 Dilation ![](https://i.imgur.com/QgemBCX.png) * Close : 先進行 Dilation,再進行 Erosion ![](https://i.imgur.com/UDuLEmS.png) 在本次應用中,預計兩者都使用,以達到消除雜訊的功能。雖然在使用 HSV 過濾時,已經有夠好的效果。但考量日後可能在不同場地進行測驗,因此仍然加入該程式。 * Open ![](https://i.imgur.com/w1c6UKb.png) * Closed ![](https://i.imgur.com/1RAwmXL.png) * Processed ![](https://i.imgur.com/RLoRtS0.jpg) 相關程式如下: ```python= maskOpen = cv2.morphologyEx(mask,cv2.MORPH_OPEN,kernelOpen) maskClose = cv2.morphologyEx(maskOpen,cv2.MORPH_CLOSE,kernelClose) maskFinal = maskClose _,conts,_ = cv2.findContours(maskFinal.copy(),\ cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_NONE) ``` ### 循跡線 能夠辨識出一個圖形中的深色部分之後,接著更進一步找出循跡線。我們看總和以上各個部分,設計一個尋找接近黑線形狀的 將螢幕沿 y 方向分割成數個部分,分別找出該分割中循跡線線段的質心,最後將所有點連接起來,可以大致得到線段中各個點。 ```python= def extract_polygon(img, slice_num=16, LB=np.array([0,0,0]), UB=np.array([180,255,75])): IMG_HEIGHT, IMG_WIDTH,_ = img.shape X_DIV = int(IMG_HEIGHT/float(slice_num)) kernelOpen = np.ones((5,5)) kernelClose = np.ones((20,20)) poly_points = [None] * slice_num detected_contours = [None] * slice_num for i in range(0, slice_num) : sliced_img = img[X_DIV*i + 1:X_DIV*(i+1), int(0):int(IMG_WIDTH)] blur = cv2.GaussianBlur(sliced_img,(5,5),0) imgHSV = cv2.cvtColor(blur, cv2.COLOR_BGR2HSV) mask = cv2.inRange(imgHSV, LB, UB) maskOpen = cv2.morphologyEx(mask,cv2.MORPH_OPEN,kernelOpen) maskClose=cv2.morphologyEx(maskOpen,cv2.MORPH_CLOSE,kernelClose) maskFinal = maskClose _,conts,_ = cv2.findContours(maskFinal.copy(),\ cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_NONE) if(conts): c = max(conts, key = cv2.contourArea) M = cv2.moments(c) poly_points[i] = (int(M['m10']/M['m00']), int(M['m01']/M['m00']) + X_DIV * i) detected_contours[i] = c contours = [i for i in detected_contours if i is not None] points = [i for i in poly_points if i is not None] return points, contours ``` 以 `slice_num = 8` 為例,該函式得到結果示意如下: * 原圖: ![](https://i.imgur.com/FsieaIB.jpg) * 結果: ![](https://i.imgur.com/kRczriD.jpg) 取得各點質心位置之後,可以進一步得到各種資訊:割線角度、切線角度,甚至可以由資料擬合出曲線。在本次應用中,我們僅使用曲線的質心位置,以及曲線的割線角度。 ### 凸包 成功讀取曲線之後,接下來發現另外一個問題:因為是依照顏色深淺,因此 若影像邊緣有其他陰影,則可能誤判循跡線。舉例如下面的狀況: ![](https://i.imgur.com/FYqQvz4.jpg) 在影像的邊緣出現其他東西(實際上是電腦椅的輪子)。`extract_polygon` 函式讀取直線的結果如下: ![](https://i.imgur.com/4BERaCa.jpg) 雖然在循跡時,未必會發生如圖片中極端的狀況,但在一些狀況中,如場地靠近窗戶時,窗櫺的陰影有可能會造成類似的影響: ![](https://i.imgur.com/aZQQPYZ.jpg) 雖然影響不若上述例子大,但仍會對循跡形成偏誤。因此在辨識過程當中,必須排除該狀況。針對這點,採取的作法如下: 1. 計算所有線段點形成之「凸包(convex hull)」。 2. 計算凸包圍出的多邊形面積。 3. 若該面積太大(這裡以「影像總面積的 1/50」作為基準),則捨棄該資料,沿用上一次循跡的參數。 在計算幾何中,一群點 $P$ 的凸包 $Q$ ,若且唯若滿足: 1. $Q$ 為 $P$ 的子集。 2. $Q$ 中的所有點,在平面上形成一凸多邊形 $CH(Q)$。 3. 對於任意 $p_i \in P$,$p_i$ 均在 $CH(Q)$ 內部,或是 $CH(Q)$ 的邊界。 示意圖如下: ![](https://i.imgur.com/vzQIyDY.png) 在上圖中,$Q = {p_{10}, p_2, p_1, p_0, p_{12}}$ 形成之凸多邊形 $CH(Q)$ 為 $P$ 的凸包。 (來源:Introduction to Algorithms Third Edition, THOMAS H. CORMEN, CHARLES E. LEISERSON) 判定凸包的用意在於:如果在攝影機視野範圍,內有黑線以外的深色部分(如:陰影)被偵測到,以致於產生誤判時,各點形成之凸包面積將會遠大於正確觀測到循跡線時,各點形成之凸包面積。如下圖: * 無陰影干擾時: ![](https://i.imgur.com/N6qYQjO.jpg) ![](https://i.imgur.com/PtfLayT.jpg) * 有陰影干擾時: ![](https://i.imgur.com/uJvtNqA.jpg) ![](https://i.imgur.com/AAfWksE.png) 兩相比較可以發現兩者面積差異極大,因此選擇以該作法作為抵抗干擾的方法。相關程式碼如下: ```python= path = extract_polygon(img, resolution) hull = cv2.convexHull(np.array(path)) area = cv2.contourArea(hull) ... if IMG_HEIGHT * IMG_HEIGHT / 50.0 < area: ctrl = ctrl_last ... ``` ## 誤差定義 在控制過程中,往往定義一個誤差(error),目標為使誤差變成 0。在循跡的過程中,該誤差要如何定義?考慮以下幾種狀況: 1. 定義循跡線質心距離中央的 x 座標為誤差:該方法類似於紅外線感應器陣列。然而,在某些狀況下,該方法並不合適。考慮以下狀況: ![](https://i.imgur.com/WAyAvKV.jpg) 黑線在影像中呈現對角線時,該線質心位置與原點距離並不太遠,因此並不會做出角度太大的轉向。但實際上,當黑線在影像中呈現該狀況時,車子正準備經歷一個大角度之轉彎。這是僅使用質心位置時,無法察覺的狀況。因此需要修正。 2. 定義黑線距垂直線的角度為誤差:既然能夠辨識角度,直接令伺服馬達依照黑線的角度轉,亦為一種直覺的策略。然而該作法缺點為:最多僅能令車子依照循跡線平行之軌跡運動,因此在轉彎處容易有「切西瓜」的狀況。另外,考慮以下狀況: ![](https://i.imgur.com/innIaMZ.jpg) 影像中,循跡線角度往左,因此若僅使用角度作為判斷依據,判斷結果為往左方轉彎。然而在該狀況下,往左轉將反而會使車身與循跡線偏移越來越多,最後完全看不見循跡線。因此該作法亦非最佳方法。 由上述討論可知:僅使用循跡線角度或質心位置規劃循跡演算法,都有其侷限性。 那麼,該如何定義誤差?試思考人的駕車習慣: 1. 若偏離道路中央太多,那麼會調整方向盤,使車子在道路中央。 2. 若車子在道路中央,但道路正在轉彎時,那麼會隨著道路方向進行轉向。 3. 如果在越遠的地方才開始出現彎道,那麼當下轉彎的修正量越少。 結合以上,我們可以知道: 1. 當循跡線在影像越邊緣時,「質心位置」的資訊越重要,角度相對較不重要。 2. 當循跡線質心大致在畫面中央時,「質心位置」資訊並不重要,但「角度」重要性大大提高。 3. 當質心 y 座標越大,轉彎的角度較少。 總和以上,我們認為:若直線的角度 $\theta$ 與直線的質心位置 $CM = [x, y]$,則誤差 $\phi = \phi(x, y, \theta)$,定義: $$\begin{cases} \phi_x = \frac {x}{w} \cdot \pi\\\\ \phi_\theta = \theta \end{cases}$$ 則: $$\frac {\phi}{\pi} = e^{-ky}\left(W_\theta \phi_\theta + W_{x}\phi_x\right)$$ 其中: $$\begin{cases}W_\theta + W_x = 1\\ \\ W_x \to 0\ ,W_{\theta} \to 1\ if\ x \to 0\\ \\ W_x \to 1\ ,W_{\theta} \to 0\ if\ |x| \to \frac {W}{2} \end{cases}$$ 取: $$ \begin{cases} W_x = \frac {|x|}{W/2}\\ \\ W_\theta = 1 - \frac {|x|}{W/2} \end{cases} $$ 則: $$\phi = e^{-\frac {ky}{H/2}}\left(\left(1 - \frac {|x|}{W/2}\right)\theta + \left(\frac {|x|}{W/2}\right)\frac {x}{W/2} \cdot \pi\right)$$ 其中$H$ 是影像長度,$W$ 是影像寬度,$k$ 是某一非負實數。該函數在 $x \to 0$ 時,$\phi \to \theta$,即在線靠近中央時,需要轉向的角度接近直線之角度; 而當 $|x| \to \frac {W}{2}$ 時,則越偏向以偏移量決定轉彎角度。該函數為程式中的 `evaluate_function()`: ```python= def evaluate_function(angle_part, translate_part, x, y): x = abs(x) return math.exp(-1*y/IMG_HEIGHT/20.)*( (1 - 1.*x/IMG_WIDTH/2.0)*angle_part + 1.*x/IMG_WIDTH/2 * translate_part) ``` 以該函數作為 error 的指標,控制目標為使得 $\phi = 0$,並進行 PID 控制。 ## PID 控制 1. P : evaluation function 的值 2. I : 使用 Queue 進行實作,取 2000 個資料點之加總值 3. D : 紀錄上一次迴圈的 $\phi$ 值,與這次的 $\phi$ 值相減 本次使用的 PID 控制器物件實作如下: ```python= class PID_controller(): def __init__(self, KPID, QUEUE_SIZE = 2000): self.PID = list([0., 0., 0.]) self.KPID = list([1.*KPID[0], 1.*KPID[1]/QUEUE_SIZE, 1.*KPID[2]]) self.integral_ghb = Queue.Queue(QUEUE_SIZE) self.last_tmp = 0 self.ctrl = 0 self.queue_size = QUEUE_SIZE ... ``` 其中 `PID` 為 PID 各值的大小,P 值對應 `PID[0]`,I 值對應 `PID[1]`,D 值對應 `PID[2]`。、`KPID` 為 $K_P$, $K_I$, $K_D$ 的大小,其對應方式同 `PID` 振率。`integral_ghb` 為一個先進先出(first-in first-out) 的佇列(queue),來方便數值積分計算。 該物件中 `PID_controller.step()` 成員函數為主要的計數方式: ```python= class PID_controller(): ... def step(self, cur_data): self.PID[0] = cur_data self.PID[2] = cur_data - self.last_tmp self.last_tmp = cur_data if not self.integral_ghb.full(): self.integral_ghb.put(cur_data) self.PID[1] += cur_data else: self.PID[1] = self.PID[1] - self.integral_ghb.get() + cur_data self.integral_ghb.put(cur_data) self.ctrl = sum(self.PID[i] * self.KPID[i] for i in range(3))/sum(self.KPID) ... ``` 在 `integral_ghb` 未滿之前,資料會持續填入該佇列當中,並且`PID[1]` 不斷加上新紀錄的值。直到 `integral_ghb` 滿了後,每次有新資料進入時,PID[1] 將減去 `integral_ghb` 內部最老的那筆數據,並且加上最新的數據,藉此可以再 $O(1)$ 的時間內得到新的積分數據,而不用每次都將 `integral_ghb` 內部的數據從頭加總。 # 電路 ![](https://i.imgur.com/3Mdl1Kg.png) 由 Raspberry Pi 經由 74HCT244 緩衝閘,及輸出 PWM 訊號控制馬達。 註:實際使用的電池為 3 顆 18650 串連 ## 電源供給 串連 3 顆 18650 來達成 11.1 V (實際上範圍約 10.6 ~ 12.5V)的電壓,並且並聯一個變壓模組,輸出 5.5V 電壓,供給各 IC 與伺服馬達電力。 最初考量鋰聚電池(Li-Po),如同過去多數學長姐的作法。但該電池較昂貴,而且需要另外添購充電裝置,使用上較不方便。經過思考過後,決定使用 18650 鋰離子電池串連。除了電池更換方便之外,市面上有許多電芯採用 18650 電池的行動電源,因此僅需將既有的行動電源稍微改裝,便可得到一個現成的 18650 充電器。亦可將既有的行動電源插入 18650 電池後,作為一個 5V 供電,供給 Raspberry Pi 。此外,在專題結束後,暫時不用的 18650 電池,也可以直接改裝成手機的行動電源。可以說是一個經濟的選擇。 ![](https://i.imgur.com/9wNOhSp.jpg) ## 邏輯準位轉換 Raspberry Pi 和 Arduino 類似,有眾多輸入、輸出腳位。然而,為了降低能耗,其 GPIO (General Purpose Input/Output) 之電路,以 3.3V 作為標準,而非 Arduino 使用的 5V 作為。這意味著:若使用專為 Arduino 設計的馬達與模組,除了單純連接電路之外,還必須處理邏輯準位的不同。該問題有以下幾種解法: 1. Logic Level Shifter:將 GPIO 輸出之電壓,施加在 MOSFET 的閘極,而源極與汲極連接至 5V。當 GPIO 輸出產生高低變化時,將控制 MOSFET 的源極與汲極導通與切斷,進而形成 5V 的 PWM。 由於直接將 GPIO 腳位連接未經保護的電路,容易因使用不當使板子毀壞,因此我們並未使用該方法。 2. 使用緩衝閘(Buffer Gate):緩衝閘(Buffer Gate) 為邏輯電路中的一種邏輯閘,其為兩個 NOT 閘串接而成,因此並不會對原邏輯電路中的真值進行任何運算,其目的為「將輸入訊號復述一遍」,以增強在邏輯閘之間傳遞的訊號強度。 ![](https://i.imgur.com/zYI2zCY.png) 假定有一個以 5V 為邏輯電路準位的緩衝閘,在輸入 Raspberry Pi 的 3.3V PWM 時,會自動將其高電位的 3.3V duty cycle 部分視為 HIGH,並且以 5V 邏輯的高電位輸出,即可得到 5V 的 PWM。 3. 主從式架構(Master-Slave Archetecture):該作法使用 Raspberry Pi 另外控制一片處理器(如 Arduino),由該處理器全權負責與周邊裝置的 I/O,兩者之間藉由 USB 或 UART 等機制溝通(在本次應用中使用 USB)。 第 3 種方法聽起來雖然疊床架屋,但是最能保護 Raspbery Pi 板子的方法,因為 I/O 當中的意外(如短路)均由負責 I/O 的處理器承擔,相較於直接燒毀 GPIO 腳位保險得多。另外,在軟體的協作方面亦有好處:較擅長 C++ 及 Arduino 程式的人,可以在 Arduino 上開發程式,最後再撰寫交換兩者資訊的程式即可。 然而,該方法面臨透過 USB 傳輸時的同步問題,即當 Arduino 端傳輸資料時,Raspberry Pi 端未必處在等待接收資料的狀態; 反之,當 Raspberry Pi 傳送控制伺服馬達的程式給 Arduino 時,Arduino 也未必接收到。因此,對於需要較快反應的伺服馬達,該作法並不適合。因此在期末測試時,決定刪除該去除 Arduino,改將所有訊號經由 74HCT244 進行 Interfacing 後,進行輸出。 另外,相較於僅需輸輸出 PWM 的伺服馬達,聲納模組同時需要同時進行輸入與輸出。因此聲納有一個獨立的雙向邏輯準位轉換 IC 。 # 物件化的程式設計 期中測試時,給乎所有程式都放在同一個檔案中。而期末測試時進一步利用物件導向的功能,將車子、PID 控制器等功能,利用物件導向的程式哲學,進一步封裝成各個物件。除了使程式維護方便外,在撰寫遙控程式時,直接使用封裝好的程式物件進行相應的動作即可。 (`ARSCHLOCH.py`) ```python= import time import pigpio import sonar_ranger import numpy as np class ARSCHLOCH: def __init__(self): self.STEER = 19 self.FAN_ANG = 13 self.ESC = 6 self.SONAR_ECHO = 17 self.SONAR_TRIG = 27 self.S_MID = 16 self.S_AMP = 11 self.MAX_DUTY = self.S_MID + self.S_AMP self.MIN_DUTY = self.S_MID - self.S_AMP self.ESC_MINDUTY = 10 self.ESC_MAXDUTY = 40 self.SERVO_PIN = [self.STEER, self.FAN_ANG] self.ESC_PIN = [self.ESC] self.SENSOR_PIN_OUT = [] self.SENSOR_PIN_IN = [] self.PWM_TEST_SEQ = [self.S_MID, self.S_MID + self.S_AMP, self.S_MID, self.S_MID - self.S_AMP] self.pi = pigpio.pi() self.ranger = sonar_ranger.ranger(self.pi, self.SONAR_TRIG, self.SONAR_ECHO) def turn_on(self): for i in self.SERVO_PIN + self.SENSOR_PIN_OUT: self.pi.set_mode(i, pigpio.OUTPUT) self.pi.set_PWM_frequency(i, 50) self.pi.set_PWM_dutycycle(i, self.S_MID) for i in self.ESC_PIN: self.pi.set_mode(i, pigpio.OUTPUT) self.pi.set_PWM_frequency(i, 50) self.pi.set_PWM_dutycycle(i, 0) for i in self.SENSOR_PIN_IN: self.pi.set_mode(i, pigpio.INPUT) self.pi.set_PWM_frequency(i, 50) def turn_off(self): print("exit routing start...") print("Halt BLDC...") for i in self.ESC_PIN: self.pi.set_PWM_dutycycle(i, 0) print("Halt Servos...") for i in self.SERVO_PIN: self.pi.set_PWM_dutycycle(i, self.S_MID) time.sleep(1) for i in self.SERVO_PIN: self.pi.set_PWM_dutycycle(i, 0) print("Stop sonar ranger ...") self.ranger.cancel() print("Clean Sensor pins...") for i in self.SENSOR_PIN_IN: self.pi.set_PWM_dutycycle(i, 0) self.pi.stop() def set_fan(self, theta): if theta < 0: theta = 0 elif theta > 180: theta = 180 duty = np.interp(theta, [0, 180], [self.MAX_DUTY, self.MIN_DUTY]) self.pi.set_PWM_dutycycle(self.FAN_ANG, int(duty)) def accelerate(self, speed = 1): if speed < 0: duty = self.ESC_MINDUTY elif speed > self.ESC_MAXDUTY - self.ESC_MINDUTY: duty = self.ESC_MAXDUTY else: duty = speed + self.ESC_MINDUTY self.pi.set_PWM_dutycycle(self.ESC, duty) def steer(self, theta): if theta < 0: theta = 0 elif theta > 180: theta = 180 theta = (np.interp(theta, [0, 180], [35, 145])) duty = np.interp(theta, [0, 180], [self.MAX_DUTY, self.MIN_DUTY]) self.pi.set_PWM_dutycycle(self.STEER, int(duty)) def get_distance(self): return self.ranger.read() def test_servo(self): for i in self.PWM_TEST_SEQ: for j in self.SERVO_PIN: self.pi.set_PWM_dutycycle(j, i) time.sleep(1) def callibrate_ESC(self): print("calibration high ...") self.pi.set_PWM_dutycycle(self.ESC, self.ESC_MAXDUTY) time.sleep(4) print("calibration low ...") self.pi.set_PWM_dutycycle(self.ESC, self.ESC_MINDUTY) time.sleep(2) self.pi.set_PWM_dutycycle(self.ESC, self.ESC_MINDUTY) print("Finish calibration !") ``` # 例外處理 如果使用 Arduino 控制,常常需要手動按下微處理器的重設扭,替程式進行重設來重新開始。一但程式行為不合預期,就必需徒手接住氣動車並強行拔掉電源使其停止。該作法雖然堪用,但過程中危險性較高。因此,若希望開發過程安全,則需要有結束程序的方法。另外,程序結束後,仍需要做各類硬體資源的清理(如:替伺服馬達轉中心位置、命令無刷馬達停止運轉),否則將有程式已經結束,但訊號仍在持續輸出的狀況。 因為使用更接近現代作業系統,因此可以善用作業系統的特徵,利用 `atexit()` 函數,註冊程序結束時的清理步驟: ```python= ... from ARSCHLOCH import * arschloch = ARSCHLOCH() arschloch.turn_on() arschloch.set_fan(170) arschloch.callibrate_ESC() atexit.register(arschloch.turn_off) ... ``` 有了這個設置,加上使用遠端連線,僅需要在電腦端中止程,就能將車子狀態回復至開始前,不需要在車子仍在運動的狀態下追車子。 # 附錄 ## 主程式 (`color_block.py`) ```python= mport cv2 import numpy as np import math import time import atexit from pid import * from geometry import * from equalization import * ON_RPI = 1 CAMERA_NO = -1 IMG_WIDTH = 640 IMG_HEIGHT = 480 NORMAL_SPEED = 3 THRUST_SPEED = 7 cam = cv2.VideoCapture(CAMERA_NO) controller = PID_controller([1000, 16, 200]) ctrl_last = 0 ctrl = 0 no_flip = 0 dist = 100000 if ON_RPI: from ARSCHLOCH import * arschloch = ARSCHLOCH() arschloch.turn_on() arschloch.set_fan(170) arschloch.callibrate_ESC() atexit.register(arschloch.turn_off) arschloch.accelerate(THRUST_SPEED) time.sleep(0.7) arschloch.accelerate(NORMAL_SPEED) def evaluate_function(angle_part, translate_part, x, y): x = abs(x) return math.exp(-0*y/IMG_HEIGHT/20.)*( (1 - 1.*x/IMG_WIDTH/2.0)*angle_part + 1.*x/IMG_WIDTH/2.0 * translate_part) while True: resolution = 16 #dist = arschloch.get_distance() _, img = cam.read() img = cv2.resize(img,(IMG_WIDTH,IMG_HEIGHT)) img = img[int(0.7*IMG_HEIGHT):IMG_HEIGHT, int(0 * IMG_WIDTH):int(1 * IMG_WIDTH)] path, poly = extract_polygon(img, resolution) if not len(path) == 0: n = len(path) hull = cv2.convexHull(np.array(path)) area = cv2.contourArea(hull) AREA = IMG_HEIGHT * IMG_WIDTH print(area/1./AREA) if AREA / 80 < area: ctrl = ctrl_last if __debug__: print("Noise Detected ! ! !") cv2.drawContours(img,[hull],0,(0,0,255),-1) else: curve_cm = [0, 0] for i in range(0, len(path)): curve_cm[0] += 1.*path[i][0]/len(path) curve_cm[1] += 1.*path[i][1]/len(path) vec_sec = [path[0][0]-path[n-1][0], path[0][1]-path[n-1][1]] ang_sec = 90 - np.angle(vec_sec[0] - vec_sec[1] * 1J, deg=True) translate_part = 90 - np.interp(curve_cm[0], [0, IMG_WIDTH], [180, 0]) angle_part = ang_sec ctrl_estimate = evaluate_function(angle_part, translate_part, \ curve_cm[0] - IMG_WIDTH/2.0, IMG_HEIGHT - curve_cm[1]) controller.step(ctrl_estimate) ctrl = controller.get_ctrl() if abs(ctrl - ctrl_last) < 2: no_flip += 1 else: no_flip = 0 accelerate = no_flip / 30 ctrl_last = ctrl if __debug__: print("Translate Part : " + str(translate_part)) print("Angle Part : " + str(angle_part)) print("flip no : " + str(no_flip)) print("ctrl : " + str(ctrl)) #print("dist : " + str(dist)) cv2.drawContours(img,[hull],0,(255,0,0),-1) if __debug__: for i in range(len(path) - 1): cv2.line(img, path[i] ,path[i + 1],(0, 255, 0),5) for i in range(len(poly) - 1): cv2.drawContours(img,poly[i],0,(255,0,0),1) cv2.imshow("cam",img) cv2.waitKey(10) if ON_RPI: arschloch.steer(90 - ctrl) ``` ## 影像處理程式 (`geometry.py`) ```python= import cv2 import math import numpy as np def extract_polygon(img, slice_num=16, LB=np.array([0,0,0]), UB=np.array([180,255,75])): IMG_HEIGHT, IMG_WIDTH,_ = img.shape X_DIV = int(IMG_HEIGHT/float(slice_num)) kernelOpen = np.ones((5,5)) kernelClose = np.ones((20,20)) poly_points = [None] * slice_num detected_contours = [None] * slice_num for i in range(0, slice_num) : sliced_img = img[X_DIV*i + 1:X_DIV*(i+1), int(0):int(IMG_WIDTH)] blur = cv2.GaussianBlur(sliced_img,(5,5),0) imgHSV = cv2.cvtColor(blur, cv2.COLOR_BGR2HSV) mask = cv2.inRange(imgHSV, LB, UB) maskOpen = cv2.morphologyEx(mask,cv2.MORPH_OPEN,kernelOpen) maskClose=cv2.morphologyEx(maskOpen,cv2.MORPH_CLOSE,kernelClose) maskFinal = maskClose _,conts,_ = cv2.findContours(maskFinal.copy(),\ cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_NONE) if(conts): c = max(conts, key = cv2.contourArea) M = cv2.moments(c) poly_points[i] = (int(M['m10']/M['m00']), int(M['m01']/M['m00']) + X_DIV * i) detected_contours[i] = c contours = [i for i in detected_contours if i is not None] points = [i for i in poly_points if i is not None] return points, contours ``` ## 循跡車物件 (`ARSCHLOCH.py`) ```python= import time import pigpio import sonar_ranger import numpy as np class ARSCHLOCH: def __init__(self): self.STEER = 19 self.FAN_ANG = 13 self.ESC = 6 self.SONAR_ECHO = 17 self.SONAR_TRIG = 27 self.S_MID = 16 self.S_AMP = 11 self.MAX_DUTY = self.S_MID + self.S_AMP self.MIN_DUTY = self.S_MID - self.S_AMP self.ESC_MINDUTY = 10 self.ESC_MAXDUTY = 40 self.SERVO_PIN = [self.STEER, self.FAN_ANG] self.ESC_PIN = [self.ESC] self.SENSOR_PIN_OUT = [] self.SENSOR_PIN_IN = [] self.PWM_TEST_SEQ = [self.S_MID, self.S_MID + self.S_AMP, self.S_MID, self.S_MID - self.S_AMP] self.pi = pigpio.pi() self.ranger = sonar_ranger.ranger(self.pi, self.SONAR_TRIG, self.SONAR_ECHO) def turn_on(self): for i in self.SERVO_PIN + self.SENSOR_PIN_OUT: self.pi.set_mode(i, pigpio.OUTPUT) self.pi.set_PWM_frequency(i, 50) self.pi.set_PWM_dutycycle(i, self.S_MID) for i in self.ESC_PIN: self.pi.set_mode(i, pigpio.OUTPUT) self.pi.set_PWM_frequency(i, 50) self.pi.set_PWM_dutycycle(i, 0) for i in self.SENSOR_PIN_IN: self.pi.set_mode(i, pigpio.INPUT) self.pi.set_PWM_frequency(i, 50) def turn_off(self): print("exit routing start...") print("Halt BLDC...") for i in self.ESC_PIN: self.pi.set_PWM_dutycycle(i, 0) print("Halt Servos...") for i in self.SERVO_PIN: self.pi.set_PWM_dutycycle(i, self.S_MID) time.sleep(1) for i in self.SERVO_PIN: self.pi.set_PWM_dutycycle(i, 0) print("Stop sonar ranger ...") self.ranger.cancel() print("Clean Sensor pins...") for i in self.SENSOR_PIN_IN: self.pi.set_PWM_dutycycle(i, 0) self.pi.stop() def set_fan(self, theta): if theta < 0: theta = 0 elif theta > 180: theta = 180 duty = np.interp(theta, [0, 180], [self.MAX_DUTY, self.MIN_DUTY]) self.pi.set_PWM_dutycycle(self.FAN_ANG, int(duty)) def accelerate(self, speed = 1): if speed < 0: duty = self.ESC_MINDUTY elif speed > self.ESC_MAXDUTY - self.ESC_MINDUTY: duty = self.ESC_MAXDUTY else: duty = speed + self.ESC_MINDUTY self.pi.set_PWM_dutycycle(self.ESC, duty) def steer(self, theta): if theta < 0: theta = 0 elif theta > 180: theta = 180 theta = (np.interp(theta, [0, 180], [35, 145])) duty = np.interp(theta, [0, 180], [self.MAX_DUTY, self.MIN_DUTY]) self.pi.set_PWM_dutycycle(self.STEER, int(duty)) def get_distance(self): return self.ranger.read() def test_servo(self): for i in self.PWM_TEST_SEQ: for j in self.SERVO_PIN: self.pi.set_PWM_dutycycle(j, i) time.sleep(1) def callibrate_ESC(self): print("calibration high ...") self.pi.set_PWM_dutycycle(self.ESC, self.ESC_MAXDUTY) time.sleep(4) print("calibration low ...") self.pi.set_PWM_dutycycle(self.ESC, self.ESC_MINDUTY) time.sleep(2) self.pi.set_PWM_dutycycle(self.ESC, self.ESC_MINDUTY) print("Finish calibration !") if __name__ == "__main__": import atexit arschloch = ARSCHLOCH() arschloch.turn_on() atexit.register(arschloch.turn_off) # test program goes here. ``` ##