光流法應用——自適應檢測視訊火車速度

老潘家的潘老師發表於2021-03-01

 

本文參考資料:
[1] OpenCV-Python Tutorials » Video Analysis » Optical Flow
[2] Good Features to Track
[3] Pyramidal Implementation of the Lucas Kanade Feature Tracker Description of the algorithm

程式碼地址:https://github.com/divertingPan/video_scanner/blob/main/main_v0.2.py

本篇是接續【硬核攝影】給火車拍個全身照的內容。使用自動指令碼生成火車視訊掃描圖存在一個問題:如果火車的運動速度是變化的,只使用手動給定的固定掃描間隔是會有大問題的。例如下圖。

顯然,火車在減速,車頭位置掃描間隔正合適,而車尾的掃描間隔太大了。如果能夠根據當前車速自動判斷應該用多寬的掃描間隔就能夠解決這個問題。

那麼就應該獲得前後兩幀之間物體運動的距離,最好連方向也能判斷出來,這樣直接就可以自動判斷拼接方向了。

顯然,這個需求完全可以用光流法來實現。具體的演算法原理和例程在開頭的參考資料內,留作課後閱讀材料。利用opencv可以直接獲取視訊中關鍵點在前後兩幀的定位,利用這個定位的橫向差值可以獲得這一刻的物體運動速度,差值的正負則代表運動方向。這樣利用自動檢測的運動資訊就可以實現變速物體的掃描了,並且還可以省下自己去數格子算運動距離的精力。

先獲取兩個相鄰幀,轉成灰度影像

vc = cv2.VideoCapture(video_path)
rval = vc.isOpened()
vc.set(cv2.CAP_PROP_POS_FRAMES, 300)
rval, frame_1 = vc.read()
rval, frame_2 = vc.read()

frame_1_gray = cv2.cvtColor(frame_1, cv2.COLOR_BGR2GRAY)
frame_2_gray = cv2.cvtColor(frame_2, cv2.COLOR_BGR2GRAY)

 

計算光流的第一步要獲取影像的關鍵點,這些關鍵點將作為追蹤運動情況的標靶,這裡對於關鍵點的檢測可以指定一個mask蒙版,檢測時只檢測蒙版內的區域。可以作為一個粗篩手段,避免背景干擾。這個mask可以在UI介面裡做成一個根據左邊影像欄自己制定區域的功能。

feature_params = dict(maxCorners=20,
                      qualityLevel=0.3,
                      minDistance=3,
                      blockSize=5)
mask = np.zeros((frame_height, frame_width), dtype='uint8')
mask[frame_height//2:frame_height//2+600, position-300:position+300] = 1
p0 = cv2.goodFeaturesToTrack(frame_1_gray, mask=mask, **feature_params)

 

然後計算光流,得到匹配的關鍵點good_newgood_old

lk_params = dict(winSize=(15,15),
                  maxLevel=5,
                  criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03))
p1, st, err = cv2.calcOpticalFlowPyrLK(frame_1_gray, frame_2_gray, p0, None, **lk_params)
good_new = p1[st==1]
good_old = p0[st==1]

 

畫出來一下看看究竟對不對

line = np.zeros_like(frame_1)
frame = frame_overlay
for i,(new,old) in enumerate(zip(good_new,good_old)):
    a,b = new.ravel()
    c,d = old.ravel()
    line = cv2.line(line, (a,b), (c,d), [0,255,255], 1)
    frame = cv2.circle(frame, (a,b), 3, [0,0,255], -1)
    frame = cv2.circle(frame, (c,d), 3, [0,255,0], -1)
img = cv2.add(frame, line)
cv2.imwrite('optical_flow.jpg', img)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
plt.imshow(img)
plt.show()

 

這時,我們計算good_newgood_old之間在x軸上的差值,就能獲得運動的距離,但是由於偶爾會有干擾點或者誤差點,所以我們直接取這一堆數裡的眾數,作為實際的運動距離。

moving_distance = [int(good_new[i, 0]-good_old[i, 0]) for i in range(len(good_new)) if abs(good_new[i, 1]-good_old[i, 1]) < 2]
width = max(moving_distance, default=None, key=lambda v: moving_distance.count(v))

 

這個差值如果是正值則代表後一幀在前一幀的右邊,那麼運動距離就是從左到右,是負值的話就反之。這個正負就可以作為運動方向的判斷。

由於這裡用到的是不定的width,所以拼接圖時有兩種方法可選擇:一種是動態地拼接圖片,這個好處是程式簡單,缺點是非常慢,尤其在圖片越拼越大之後。一個10000多幀的錄影,老潘洗完澡回來發現還沒拼完。

所以不推薦上述方法,建議利用第二種方法,事先初始化一個空圖片矩陣,這樣的話,方法和上一版本的基本一致,唯一有大變樣的地方在於計算正確的矩陣大小。也就是圖片長度。

自適應檢測運動間隔的大體思路如下:首先手動指定一個開始運動檢測的第一個關鍵幀(避免開頭無運動火車的影響),這個關鍵幀之前以及這個關鍵幀之後的一段內,width為此關鍵幀檢測到的火車運動距離,往後有第二個檢測關鍵幀,後續的一小段所用的width為第二關鍵幀檢測到的火車運動距離,第三關鍵幀及以後同理。所以還需要指定一個檢測靈敏度,這個靈敏度就是關鍵幀的數量,越多越能靈活應對變速情況。(靈敏度=1即只抽一幀進行速度檢測,適合勻速情況,靈敏度=總幀數即每一幀都進行運動檢測,適合蛇皮走位的極度複雜情況,但每幀都要計算光流會慢到爆),具體靈敏度可以根據上一篇裡面1畫素能夠接納的火車運動速度變化區間來指定。

視窗寬度為1畫素,則火車速度就應該為6.83x60 mm/s,即0.41m/s。

這種情況下,火車在±0.41m/s內的速度變化並不會影響到當前的掃描區間結果。

具體計算時為了方便起見,利用列表來管理關鍵幀的位置和對應的運動速度(width),為了防止短時間內可能出現的檢測誤差情況,向後檢測連續兩次光流取均值獲取更穩的結果。 在迴圈外又額外增加了一下img_length是因為adaptive_length//adaptive_sensitivity的整除可能會導致最後有幾幀被遺漏,通過這一行可以修正img_length的數值。經過老潘的手動計算以及實際測試,這樣的圖片長度是剛好的。

img_length = 0
width_list = []
width_adjust_position = []
for i in range(adaptive_sensitivity):
    print(adaptive_start + i * (adaptive_length//adaptive_sensitivity))
    vc.set(cv2.CAP_PROP_POS_FRAMES, adaptive_start + i * (adaptive_length//adaptive_sensitivity))
    rval, frame_1 = vc.read()
    rval, frame_2 = vc.read()
    rval, frame_3 = vc.read()
    width_list.append((optical_flow(frame_1, frame_2)+optical_flow(frame_2, frame_3))//2)
    if i == 0:
        img_length = width_list[0] * (adaptive_start + (adaptive_length//adaptive_sensitivity))
        width_adjust_position.append(0)
    else:
        img_length += width_list[i] * (adaptive_length//adaptive_sensitivity)
        width_adjust_position.append(adaptive_start + i * (adaptive_length//adaptive_sensitivity))
img_length += width_list[-1] * (adaptive_length - (adaptive_length//adaptive_sensitivity) * adaptive_sensitivity)
        
img = np.empty((frame_height, abs(img_length), 3), dtype='uint8')

 

之後還有一個難點是如何在遍歷視訊幀時知道當前處於哪個速度區間內?老潘想了一個鬼點子:利用當前幀的序號減關鍵幀的列表,統計列表裡面值<=0的數量,這個數量-1,就應該是當前幀所對應的關鍵幀之間的速度區間。而且對於img的操作,pixel_start的計算邏輯也應該變一下,讓他像指標一樣跟隨進度變化而改變自己的指向位置。

if width_list[0] > 0:
    pixel_start = img_length
    for i in range(total_frames):
        rval, frame = vc.read()
        if not rval:
            print('break')
            break
        
        width = width_list[((width_adjust_position - i) <= 0).sum() -1]
        
        pixel_start -= width
        pixel_end = pixel_start + width
        img[:, pixel_start:pixel_end, :] = frame[:, position:position + width, :]
        
        if i % 100 == 0:
            print('{}/{} - {}'.format(i, total_frames, width))

 

如果速度為負則反之,和上述大同小異,只是指標變化情況稍微變一下:

else:
    pixel_start = 0
    for i in range(total_frames):
        rval, frame = vc.read()
        if not rval:
            print('break')
            break
        width = abs(width_list[((width_adjust_position - i) <= 0).sum() -1])
        
        pixel_end = pixel_start + width
        img[:, pixel_start:pixel_end, :] = frame[:, position:position + width, :]
        pixel_start += width
        
        if i % 100 == 0:
            print('{}/{} - {}'.format(i, total_frames, width))

 

上一版本的影像儲存可以直接用,但是由於獲取影像長度的方法不太好,這一版本里改為img.shape[1]獲取圖片長度,替換原本的什麼幀數乘width的複雜操作。

之後將這些功能整合到原本的UI程式裡面。加一下控制元件互動(程式碼略,可自行閱讀原始碼),修改後的佈局介面如下,選擇manual或者adaptive則會使用對應功能的值,另一部分的值不會起作用。

至於識別的準確率,準起來比老潘手動去數格子都要準,但是偶爾也會有識別失誤的情況,根據多次測試,這種情況是mask沒有很好罩住車體,有一部分干擾前景或背景(例如行人的走動、草木被風吹動)影響了被檢測的運動點。此時調整mask的範圍,即可解決。

 

 

 

相關文章