Tennis Ball Tracking

트래킹은 연속되는 영상에서 검출된 물체를 지속적으로 포커싱하는 것이다. 테니스 경기영상에서는 테니스공이나 플레이어가 목표가 된다. 이번 포스팅은 실제 경기가 아닌 아마추어 영상에서의 경기시 필요한 요구사항을 파악한 후 그것에 맞춘 트래킹을 구현해 볼 것이다.

개발환경

일반적인 트래킹 알고리즘

영상처리에서 일반적으로 잘 알려진 트래킹 알고리즘은 아래와 같다.

3개의 알고리즘 모두 현재 프레임과 이전 프레임의 분포도특징점의 유사성을 비교하여 물체를 추적하지만
테니스 볼의 경우 매우 빠르게 움직이기에 위 알고리즘으로는 한계가 있다.

테니스 경기 환경에서의 트래킹을 위한 요구사항

우선 테니스 볼 속도는 일반적으로 100km/h를 가뿐히 넘는다. 그렇기에 프레임 별로 검출되는 볼의 거리는 매우 넓다.
때문에 가장 중요한 것은 볼 검출과 해당 검출이 이전 프레임에서 어떤 볼인지를 찾는 과정이 필요하다.
또한 연습 경기장의 특성상으로 영상 속에 테니스 경기가 1개 이상의 플레이가 발생할 수 있다.
이는 공 검출에 있어 단일이 아닌 2개 이상 처리 해야하는 요구사항도 있다.

정리하자면

따라서 우리는 검출된 공이 어떤 공인지 정확하게 군집시키는 것에 주력해야한다.

데이터 클래스 작성

개발 편의를 위해 테니스 볼에 대한 데이터 클래스를 작성한다.

# 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개를 생성)

추적 클래스 작성

추적을 저장하고 선별할 수 있는 클래스를 만들어보자.

테니스볼 추적은 아래와 같은 조건을 가진다.

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에 따라 프레임에 원을 그려내는 코드이다.

tracking_simple 자세히 들어다보면 매 프레임마다 생성되는 또는 잡음들이 주위 군집으로 포함되어지는 것을 확인할 수 있다.

볼 움직임의 특성

플레이어의 라켓에 맞은 뒤 볼의 움직임을 보면 중력에 의해 2차원 곡선으로 움직인다. 따라서 기존 히스토리의 추적 데이터를 기반으로 2차 회귀 방정식을 구한 뒤, 후보로 선택된 볼 중에 2차 회귀 모델에 가장 적은 Loss의 볼을 선택하자 regression

포인터 데이터들을 가지고 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.polyfitpoly1d의 사용법은 구글링을 통해 쉽게 알수 있다.
적당한 히스토리가 존재해야 어느정도의 회귀 모델을 구할 수 있으며 부족하다면 가장 거리가 가까운걸로 선택하게 하자 회귀모델이 구해진다면 모델과 볼의 Loss정도가 가장 작은 볼을 반환하면 된다.

여기까지 결과물을 확인해보면 tracking 해당 영상에서는 크게 차이를 느끼지 못할 것이다. 하지만 여러공이 움직이는 연습경기장에서 볼 추적 중간에 방해가 되는 물체가 들어오면 개선의 차이를 확인할 수 있을 것 같다.

전체 코드

# 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 체크테니스 코트 검출을 하여 경기 위에서 바로 보는듯한 효과를 주는 와핑처리까지 포스팅하겠다.