귀퉁이 서재

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

11 Comments
  • 프로필사진 dd 2021.06.28 17:24 안녕하세요
    광학흐름 feature tracker와 feature matcher의 차이를 알고 싶어서 댓글 남깁니다 감사합니다~~~
  • 프로필사진 Baek Kyun Shin 2021.06.29 21:47 신고 안녕하세요~
    우선 읽어주셔서 감사드립니다.
    tracker는 한 물체의 움직임을 관찰하는 메서드고, matcher는 두 이미지에서 같은 지점이 어딘지 판별해주는 메서드입니다.
  • 프로필사진 호두 2021.08.30 15:25 안녕하세요! 유용한 정보 잘 읽었습니다!
    궁금한게 있는데 혹시 LK 알고리즘에서 포인트들이 장애물을 지나면 객체를 놓치는데 이부분을 개선할 방법이 있을까욤??
  • 프로필사진 Baek Kyun Shin 2021.08.30 23:41 신고 여기서 설명한 알고리즘은 이미지상에서 객체가 보이는대로 추적하기 때문에 장애물을 만나면 당연히 놓칠 수밖에 없을 것 같습니다. 장애물을 만나기 전후로 똑같은 객체를 탐지한다면 추적을 이어주는 방법이 있을 것 같다는 막연한 생각이 드네요. 말씀하신 개선 방법은 이 책의 범위를 넘어가서 저도 잘 모르겠네요. 죄송합니다 ㅜ.ㅜ
  • 프로필사진 호두 2021.08.31 08:38 windsize랑 피라미드 레벨을 늘리니까 어느정도 개선됬어욤!!ㅎㅎ 댓글감사합니다ㅏㅏ
  • 프로필사진 Baek Kyun Shin 2021.08.31 23:13 신고 아 윈도 크기가 있었군요. 감사합니다 ^^
  • 프로필사진 오류 2022.01.19 16:44 안녕하세요^^ 요새 저도 opencv를 배우고 있어 방문하게 되었습니다.
    같은 데이터 같은 코드로도 Lucas-Kanade 부분에서자꾸 오류가 나길래 먼가 했더니,
    lines와 circle 함수의 px, py, nx, ny가 float 형이라서 오류가 떳네요.
    int로 형변환 시켜주니까 잘 됩니다. 글 작성한 뒤 지금까지 1년 사이에 opencv함수가 바뀌었나봐요.
    int로 형변환 시켜줘서 뭐가 달라졌을지는 저는 전에 실행해본적이 없어서 잘 모르겠네요 ^^
    처음 오시는 분들은 헤메지 않기를 바랍니다!!
  • 프로필사진 Baek Kyun Shin 2022.01.20 21:05 신고 안녕하세요! 네, 아마도 그 사이에 바뀌었나봅니다. 댓글로 수정사항 말씀해주셔서 감사드립니다 ^^
  • 프로필사진 도움 2022.02.10 17:33 같은 문제를 겪고있는데,
    구체적으로 어떤 라인을 어떤형태로 int로 바꿔주셨는지 알려 주실 수 있으신가요?

    저는
    ~'cv2.line 에서 기존의 (px,py) 를
    (int(px[0][0]), int(py[0][1])) 형태로 바꿔주니 오류가 나오네요 ㅠㅠ
  • 프로필사진 Baek Kyun Shin 2022.02.10 23:47 신고 저도 직접 테스트해보진 않아서.. ㅜ int(px), int(py)로 해도 안되나요?
  • 프로필사진 도움 2022.02.18 15:34 네,,ㅎ 그렇게 해결이 되지 않네요ㅠ
    차후에 다시 시도해봐야 될 것 같습니다.
    감사합니다 :)
댓글쓰기 폼