on
Tennis Ball Tracking
트래킹은 연속되는 영상에서 검출된 물체를 지속적으로 포커싱
하는 것이다.
테니스 경기영상에서는 테니스공
이나 플레이어
가 목표가 된다. 이번 포스팅은 실제 경기가 아닌 아마추어 영상에서의
경기시 필요한 요구사항
을 파악한 후 그것에 맞춘 트래킹을 구현해 볼 것이다.
개발환경
- PC: Mac mini M1
- OS: macOS Big Sur
- Lang: Python
- Package: opencv-python, numpy
일반적인 트래킹 알고리즘
영상처리에서 일반적으로 잘 알려진 트래킹 알고리즘은 아래와 같다.
- Optical flow
- Mean Shift, Cam Shift
- TLD (Trading, Learning, Detection)
3개의 알고리즘 모두 현재 프레임과 이전 프레임의 분포도
및 특징점
의 유사성을 비교하여 물체를 추적하지만
테니스 볼의 경우 매우 빠르게
움직이기에 위 알고리즘으로는 한계가 있다.
테니스 경기 환경에서의 트래킹을 위한 요구사항
우선 테니스 볼 속도는 일반적으로 100km/h를 가뿐히 넘는다. 그렇기에 프레임 별로 검출되는 볼의 거리는 매우 넓다.
때문에 가장 중요한 것은 볼 검출
과 해당 검출이 이전 프레임에서 어떤 볼인지를 찾는 과정
이 필요하다.
또한 연습 경기장의 특성상으로 영상 속에 테니스 경기가 1개 이상
의 플레이가 발생할 수 있다.
이는 공 검출에 있어 단일이 아닌 2개 이상 처리 해야하는 요구사항
도 있다.
정리하자면
- 프레임 단위로 움직이는
범위가 크다
. 1개 이상의 공
이 발생할 수 있다.
따라서 우리는 검출된 공이 어떤 공인지 정확하게 군집
시키는 것에 주력해야한다.
데이터 클래스 작성
개발 편의를 위해 테니스 볼에 대한 데이터 클래스
를 작성한다.
# dataclasses.py
import math
from dataclasses import dataclass
@dataclass
class Ball:
x: float
y: float
radius: float
def __iter__(self):
return iter((self.x, self.y, self.radius))
def __getitem__(self, index):
return (self.x, self.y, self.radius)[index]
def __distance(self, x1: float, y1: float, x2: float, y2: float):
dx = x1 - x2
dy = y1 - y2
return math.sqrt(dx * dx + dy * dy)
def distance(self, ball):
""" 마지막 공과의 거리 """
x, y, _ = ball
return self.__distance(self.x, self.y, x, y)
def loss(self, f: any):
""" 회귀 모델과의 오류값 """
return math.fabs(self.y - f(self.x))
다른 볼과의 거리
와 회귀
모델을 통한 오류값을 구할 수 있다.
트래킹 클래스 작성
매 프레임 단위로 새로운 볼
들이 생성된다. 생성되는 볼들을 입력으로 넣으면 각 군집
을 분류
하거나 생성
또는 제거
해내는 클래스를 작성해보자
class TennisBallTracking:
def __init__(self, maxlen=20):
self._maxlen = maxlen
self._traces = deque(maxlen=self._maxlen)
def __refresh(self):
self._traces = deque(filter(lambda x: x.size, self._traces), maxlen=self._maxlen)
def apply(self, balls: list):
copied = balls[:]
for trace in self._traces:
matched = trace.find(copied)
if matched:
trace.add(matched)
copied.remove(matched)
else:
trace.forward()
self.__refresh()
for ball in copied:
if len(self._traces) > 20: break
self._traces.append(Trace(ball, color=constants.COLOR[random.randrange(0, 6)]))
return self._traces
간단히 설명하자면 apply
함수를 통해 들어온 공들은 기존에 존재하는 Trace
에 포함이 되는지 find
작업을 진행한다. 여기서 매칭이 되면 해당 Trace
에 추가가 되지만 그렇지 않으면 Trace
를 강제로 forward
시킨다.
여기서 forward
의 역할은 Trace
를 일정한 크기로 유지시키기 위해 사용된다.
매칭이 완료되면 forward
과정에서 history
가 사라진 Trace
들이 발생한다. 이것들은 size 필터링을 통해 Refresh 해주는 작업을 진행하자
마지막으로 매칭 되지 못하고 남은 볼들은 새로운 추적의 객체로 탄생
시킨다.(최대 20개를 생성)
추적 클래스 작성
추적을 저장하고 선별할 수 있는 클래스를 만들어보자.
테니스볼 추적은 아래와 같은 조건을 가진다.
- 고정된 프레임 수 만큼
히스토리
를 저장 (default: 8) - 공이 나타나고 사라지는 추적만 표시
- 최대한 추적에 맞는 공을 선택
from . import dataclasses
class Trace:
def __init__(self, ball: Ball, color=[0, 0, 255], maxlen=8):
self._history = deque(maxlen=maxlen)
self._color = color
self.add(ball)
def add(self, ball):
self._history.appendleft(ball)
def forward(self):
self._history.pop()
def find(self, balls):
current = self._history[0]
selected = []
for ball in balls:
if ball.distance(current) < 150:
selected.append(ball)
if len(selected) == 0:
return None
selected = sorted(selected, key=lambda x: x.distance(current))
return selected[0]
@property
def history(self):
return self._history
@property
def size(self):
return len(self._history)
@property
def color(self):
return self._color
self._history
변수는 추적에 포함되는 애들을 저장해둔다.
이때 deque 자료구조
를 사용하여 일정크기의 데이터만 유지할 수 있도록 한다.
추적 클래스에서는 현 시점에 검출된 볼 리스트를 받아 추적에 포함되는 볼을 선택할 것이다.
조건은 일정거리
안에 들어오는 볼들이다.
여기까지의 과정을 동작시켜보자
detect = TennisBallDetection()
tracking = TennisBallTracking()
def test_tracking(frame):
balls = detect.apply(frame)
traces = tracking.apply(balls)
for trace in traces:
for ball in trace.history:
x, y, r = ball
cv.circle(frame_output, (int(x), int(y)), radius=int(r+2), color=trace.color, thickness=-1)
프레임별로 볼 검출
을 진행하고 생성된 볼 리스트를 트래킹 객체
에 적용시킨다. 반환하는 traces
는 현재까지 진행된 볼들의 trace
들을 저장하고 있다.
그리고 각 trace
들을 색깔별로 history
에 따라 프레임에 원을 그려내는 코드이다.
자세히 들어다보면 매 프레임마다 생성되는 공
또는 잡음
들이 주위 군집
으로 포함되어지는 것을 확인할 수 있다.
볼 움직임의 특성
플레이어의 라켓에 맞은 뒤 볼의 움직임을 보면 중력에 의해 2차원 곡선
으로 움직인다.
따라서 기존 히스토리의 추적 데이터를 기반으로 2차 회귀 방정식
을 구한 뒤, 후보로 선택된 볼 중에 2차 회귀 모델에 가장 적은 Loss
의 볼을 선택하자
포인터 데이터들을 가지고 2차 방정식 회귀모델
을 구하는 함수를 추가해보면
from . import dataclasses
class Trace:
...
def find(self, balls):
...
if len(selected) == 0:
return None
predict = self.__get_regression()
if predict:
selected = sorted(selected, key=lambda x: x.loss(f=predict))
else:
selected = sorted(selected, key=lambda x: x.distance(current))
#selected = sorted(selected, key=lambda x: x.distance(current))
return selected[0]
def __get_regression(self):
if self.size <= 3: return None
x = list(map(lambda e: e.x, self._history))
y = list(map(lambda e: e.y, self._history))
fit = np.polyfit(x, y, deg=2)
self._predict = np.poly1d(fit)
return self._predict
...
__get_regression()
을 주목해보자 Numpy.polyfit
과 poly1d
의 사용법은 구글링을 통해 쉽게 알수 있다.
적당한 히스토리가 존재해야 어느정도의 회귀 모델을 구할 수 있으며 부족하다면 가장 거리가 가까운걸로 선택하게 하자
회귀모델이 구해진다면 모델과 볼의 Loss
정도가 가장 작은 볼을 반환하면 된다.
여기까지 결과물을 확인해보면 해당 영상에서는 크게 차이를 느끼지 못할 것이다. 하지만 여러공이 움직이는 연습경기장에서 볼 추적 중간에 방해가 되는 물체가 들어오면 개선의 차이를 확인할 수 있을 것 같다.
전체 코드
# tracker.py
import random
import numpy as np
from . import constants # 컬러 정의
from collections import deque
from .dataclasses import Ball
class Trace:
def __init__(self, ball: Ball, color=[0, 0, 255], maxlen=8):
self._history = deque(maxlen=maxlen)
self._color = color
self.add(ball)
def add(self, ball):
self._history.appendleft(ball)
def forward(self):
self._history.pop()
def find(self, balls):
current = self._history[0]
selected = []
for ball in balls:
if ball.distance(current) < 150:
selected.append(ball)
if len(selected) == 0:
return None
predict = self.__get_regression()
if predict:
selected = sorted(selected, key=lambda x: x.loss(f=predict))
else:
selected = sorted(selected, key=lambda x: x.distance(current))
return selected[0]
def __get_regression(self):
if self.size <= 3: return None
x = list(map(lambda e: e.x, self._history))
y = list(map(lambda e: e.y, self._history))
fit = np.polyfit(x, y, deg=2)
self._predict = np.poly1d(fit)
return self._predict
@property
def history(self):
return self._history
@property
def size(self):
return len(self._history)
@property
def color(self):
return self._color
class TennisBallTracking:
def __init__(self, maxlen=20):
self._maxlen = maxlen
self._traces = deque(maxlen=self._maxlen)
def __refresh(self):
self._traces = deque(filter(lambda x: x.size, self._traces), maxlen=self._maxlen)
def apply(self, balls: list):
copied = balls[:]
for trace in self._traces:
matched = trace.find(copied)
if matched:
trace.add(matched)
copied.remove(matched)
else:
trace.forward()
self.__refresh()
for ball in copied:
if len(self._traces) > 20: break
self._traces.append(Trace(ball, color=constants.COLOR[random.randrange(0, 6)]))
return self._traces
…
일반적인 트래킹 기법이 아닌 테니스공 움직임에 특화된 트래킹 기법을 알아보고 구현해보았다. 여전히 테니스공이 아닌
잡음들이 충분히 잡히고 트래킹에 방해가 되는건 사실이다. 이처럼 영상처리를 단계별로 진행할시 앞 처리에 대한 의존성
이 매우 강력함으로 처리 하나하나에 검증
은 필수라 할 수 있다.
추가적으로 테니스공 검출에 딥러닝
을 이용한다면 아주 멋지게 트래킹된 영상을 볼 수 있을거라 예상한다,
다음 포스팅은 위 추적이 떨어지는 위치의 bounce 체크
와 테니스 코트 검출
을 하여 경기 위에서 바로 보는듯한 효과를 주는 와핑
처리까지 포스팅하겠다.