귀퉁이 서재

OpenCV - 31. 광학 흐름(Optical Flow) 본문

OpenCV

OpenCV - 31. 광학 흐름(Optical Flow)

Baek Kyun Shin 2020. 12. 22. 23:29

이번 포스팅에서는 객체 추적 방법인 광학 흐름에 관해 배워보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/08.match_track

광학 흐름(Optical Flow)

광학 흐름이란 영상 내 물체의 움직임 패턴을 말합니다. 이전 프레임과 다음 프레임 간 픽셀이 이동한 방향과 거리 분포입니다. 광학 흐름으로 영상 내 물체가 어느 방향으로 얼마만큼 움직였는지 파악할 수 있습니다. 더불어 추가 연산을 하면 물체의 움직임을 예측할 수도 있습니다.

출처: https://en.wikipedia.org/wiki/Optical_flow

광학 흐름은 다음 두 가지 사실을 가정합니다.

1. 연속된 프레임 사이에서 움직이는 물체의 픽셀 강도(intensity)는 변함이 없다.
2. 이웃하는 픽셀은 비슷한 움직임을 갖는다.

광학 흐름을 계산하는 방법은 두 가지입니다. 일부 픽셀만 계산하는 희소(sparse) 광학 흐름과 영상 전체 픽셀을 모두 계산하는 밀집(dense) 광학 흐름입니다.

루카스-카나데(Lucas-Kanade) 알고리즘

광학 흐름은 이웃하는 픽셀이 비슷하게 움직인다고 가정합니다. 루카스-카나데 알고리즘은 이 가정을 이용하는 알고리즘입니다. 이웃하는 픽셀은 비슷한 움직임을 갖는다고 생각하고 광학 흐름을 파악합니다. 루카스-카나데 알고리즘은 작은 윈도(3 x 3 patch)를 사용하여 움직임을 계산합니다. 그래서 물체 움직임이 크면 문제가 생깁니다. 윈도 크기가 작기 때문입니다. 이 문제를 개선하기 위해 이미지 피라미드를 사용합니다. 이미지 피라미드 위쪽으로 갈수록(이미지가 작아질수록) 작은 움직임은 티가 안 나고 큰 움직임은 작은 움직임 같아 보입니다. 이렇게 큰 움직임도 감지할 수 있습니다. 

OpenCV는 루카스-카나데 알고리즘을 구현한 cv2.calcOpticalFlowPyrLK() 함수를 제공합니다.

  • nextPts, status, err = cv2.calcOpticalFlowPyrLK(prevImg, nextImg, prevPts, nextPts, status, err, wirnSize, maxLevel, criteria, flags, minEigThreshold)
    prevImg: 이전 프레임 영상
    nextImg: 다음 프레임 영상
    prevPts: 이전 프레임의 코너 특징점, cv2.goodFeaturesToTrack()으로 검출
    nextPst: 다음 프레임에서 이동한 코너 특징점
    status: 결과 상태 벡터, nextPts와 같은 길이, 대응점이 있으면 1, 없으면 0
    err: 결과 에러 벡터, 대응점 간의 오차
    winSize=(21,21): 각 이미지 피라미드의 검색 윈도 크기
    maxLevel=3: 이미지 피라미드 계층 수
    criteria=(COUNT+EPS, 30, 0.01): 반복 탐색 중지 요건 (cv2.TERM_CRITERIA_EPS: 정확도가 epsilon보다 작으면 중지, cv2.TERM_CRITERIA_MAX_ITER: max_iter 횟수를 채우면 중지, cv2.TERM_CRITERIA_COUNT: MAX_ITER와 동일, max_iter: 최대 반복 횟수, epsilon: 최소 정확도)
    flgs=0: 연산 모드 (0: prevPts를 nextPts의 초기 값으로 사용, cv2.OPTFLOW_USE_INITAL_FLOW: nextPts의 값을 초기 값으로 사용, cv2.OPTFLOW_LK_GET_MIN_EIGENVALS: 오차를 최소 고유 값으로 계산)
    minEigThreshold=1e-4: 대응점 계산에 사용할 최소 임계 고유 값

위 함수는 영상 내 픽셀 전체를 한번에 계산하지 않습니다. cv2.goodFeaturesToTrack() 함수로 얻은 특징점만 활용하여 계산합니다. prevImg와 nextImg 파라미터에는 이전 프레임과 다음 프레임을 전달하면 됩니다. prevPts 파라미터에는 이전 프레임에서 얻은 특징점을 전달합니다. 그러면 특징점이 다음 프레임에서 어디로 이동했는지 계산하여 nextPts로 반환합니다. 두 특징점이 서로 대응하면 status 변수가 1, 그렇지 않으면 0이 됩니다. 또한, maxLevel=0이면 이미지 피라미드를 사용하지 않습니다. 

다음은 calcOpticalFlowPyrLK() 함수를 활용하여 광학 흐름을 적용한 코드입니다.

# calcOpticalFlowPyrLK 추적 (track_opticalLK.py)

import numpy as np, cv2

cap = cv2.VideoCapture('../img/walking.avi')
fps = cap.get(cv2.CAP_PROP_FPS) # 프레임 수 구하기
delay = int(1000/fps)
# 추적 경로를 그리기 위한 랜덤 색상
color = np.random.randint(0,255,(200,3))
lines = None  #추적 선을 그릴 이미지 저장 변수
prevImg = None  # 이전 프레임 저장 변수
# calcOpticalFlowPyrLK 중지 요건 설정
termcriteria =  (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03)

while cap.isOpened():
    ret,frame = cap.read()
    if not ret:
        break
    img_draw = frame.copy()
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    # 최초 프레임 경우
    if prevImg is None:
        prevImg = gray
        # 추적선 그릴 이미지를 프레임 크기에 맞게 생성
        lines = np.zeros_like(frame)
        # 추적 시작을 위한 코너 검출  ---①
        prevPt = cv2.goodFeaturesToTrack(prevImg, 200, 0.01, 10)
    else:
        nextImg = gray
        # 옵티컬 플로우로 다음 프레임의 코너점  찾기 ---②
        nextPt, status, err = cv2.calcOpticalFlowPyrLK(prevImg, nextImg, \
                                        prevPt, None, criteria=termcriteria)
        # 대응점이 있는 코너, 움직인 코너 선별 ---③
        prevMv = prevPt[status==1]
        nextMv = nextPt[status==1]
        for i,(p, n) in enumerate(zip(prevMv, nextMv)):
            px,py = p.ravel()
            nx,ny = n.ravel()
            # 이전 코너와 새로운 코너에 선그리기 ---④
            cv2.line(lines, (px, py), (nx,ny), color[i].tolist(), 2)
            # 새로운 코너에 점 그리기
            cv2.circle(img_draw, (nx,ny), 2, color[i].tolist(), -1)
        # 누적된 추적 선을 출력 이미지에 합성 ---⑤
        img_draw = cv2.add(img_draw, lines)
        # 다음 프레임을 위한 프레임과 코너점 이월
        prevImg = nextImg
        prevPt = nextMv.reshape(-1,1,2)

    cv2.imshow('OpticalFlow-LK', img_draw)
    key = cv2.waitKey(delay)
    if key == 27 : # Esc:종료
        break
    elif key == 8: # Backspace:추적 이력 지우기
        prevImg = None
cv2.destroyAllWindows()
cap.release()

cv2.goodFeatureToTrack() 함수로 이전 프레임의 특징점을 검출했습니다. cv2.calcOpticalFlowPyrLK() 함수로 광학 흐름을 계산해 다음 프레임의 특징점을 찾았습니다. 이전 프레임과 다음 프레임 특징점 중 잘 대응되는 특징점만 선별하여 선과 점으로 표시했습니다. 원본 이미지에 추적선을 합성하는 방식으로 표현했습니다. 그래야 추적선이 보입니다.

군나르 파너백(Gunner Farneback) 알고리즘

군나르 파너백 알고리즘은 밀집 방식으로 광학 흐름을 계산하는 알고리즘입니다. 위에서 설명했다시피 밀집 방식은 영상 전체의 픽셀을 활용해 광학 흐름을 계산하는 방식입니다. 이 알고리즘은 2003년 군나르 파너백의 논문(Two-Frame Motion Estimation Based on Polynomial Expansion)에 소개된 알고리즘입니다. 군나르 파너백 알고리즘을 구현하기 위해 OpenCV에서는 cv2.calOpticalFlowFarneback() 함수를 제공합니다.

  • flow = cv2.calcOpticalFlowFarneback(prev, next, flow, pyr_scale, levels, winsize, iterations, poly_n, poly_sigma, flags)
    prev, next: 이전, 이후 프레임
    flow: 광학 흐름 계산 결과, 각 픽셀이 이동한 거리 (입력과 동일한 크기)
    pyr_scale: 이미지 피라미드 스케일
    levels: 이미지 피라미드 개수
    winsize: 평균 윈도 크기
    iterations: 각 피라미드에서 반복할 횟수
    poly_n: 다항식 근사를 위한 이웃 크기, 5 또는 7
    poly_sigma: 다항식 근사에서 사용할 가우시안 시그마 (poly_n=5일 때는 1.1, poly_n=7일 때는 1.5)
    flags: 연산 모드 (cv2.OPTFLOW_USE_INITAL_FLOW: flow 값을 초기 값으로 사용, cv2.OPTFLOW_FARNEBACK_GAUSSIAN: 박스 필터 대신 가우시안 필터 사용)

밀집 광학 흐름은 희소 광학 흐름과 다르게 영상 전체 픽셀을 활용해 계산합니다. 그래서 추적할 특징점을 따로 전달할 필요가 없습니다. 다만, 전체 픽셀을 활용해 계산하므로 속도가 느립니다.

# calcOPticalFlowFarneback 추적 (track_optical_farneback.py)

import cv2, numpy as np

# 플로우 결과 그리기 ---①
def drawFlow(img,flow,step=16):
  h,w = img.shape[:2]
  # 16픽셀 간격의 그리드 인덱스 구하기 ---②
  idx_y,idx_x = np.mgrid[step/2:h:step,step/2:w:step].astype(np.int)
  indices =  np.stack( (idx_x,idx_y), axis =-1).reshape(-1,2)
  
  for x,y in indices:   # 인덱스 순회
    # 각 그리드 인덱스 위치에 점 그리기 ---③
    cv2.circle(img, (x,y), 1, (0,255,0), -1)
    # 각 그리드 인덱스에 해당하는 플로우 결과 값 (이동 거리)  ---④
    dx,dy = flow[y, x].astype(np.int)
    # 각 그리드 인덱스 위치에서 이동한 거리 만큼 선 그리기 ---⑤
    cv2.line(img, (x,y), (x+dx, y+dy), (0,255, 0),2, cv2.LINE_AA )


prev = None # 이전 프레임 저장 변수

cap = cv2.VideoCapture('../img/walking.avi')
fps = cap.get(cv2.CAP_PROP_FPS) # 프레임 수 구하기
delay = int(1000/fps)

while cap.isOpened():
  ret,frame = cap.read()
  if not ret: break
  gray = cv2.cvtColor(frame,cv2.COLOR_BGR2GRAY)
  # 최초 프레임 경우 
  if prev is None: 
    prev = gray # 첫 이전 프레임 --- ⑥
  else:
    # 이전, 이후 프레임으로 옵티컬 플로우 계산 ---⑦
    flow = cv2.calcOpticalFlowFarneback(prev,gray,None,\
                0.5,3,15,3,5,1.1,cv2.OPTFLOW_FARNEBACK_GAUSSIAN) 
    # 계산 결과 그리기, 선언한 함수 호출 ---⑧
    drawFlow(frame,flow)
    # 다음 프레임을 위해 이월 ---⑨
    prev = gray
  
  cv2.imshow('OpticalFlow-Farneback', frame)
  if cv2.waitKey(delay) == 27:
      break
cap.release()
cv2.destroyAllWindows()

drawFlow() 함수로 16픽셀 간격으로 격자 모양의 점을 찍었습니다. 각 점에서 해당하는 픽셀이 이동한 만큼 선으로 표시했습니다. 희소 광학 흐름과 다르게 밀집 광학 흐름은 영상 전체에서 일어나는 움직임을 감지할 수 있습니다.

Comments