[Deep Learning] Yolov5를 이용한 풋살 경기 볼 소유율 자동 분석

김서현·2023년 5월 15일
0
post-thumbnail

📌 풋살 자동 경기 분석 항목

우리가 이 중 가장 집중한 항목은 볼 소유율이다.
풋살과 가장 비슷한 스포츠인 축구에서는 분석 항목에 필수적으로 들어가는 것이 점유율이기 때문이다.

점유율의 정확한 정의는 다음과 같다

점유율
팀 패스수 / (팀 패스 수 + 상대팀 패스 수)

실제 경기에서 패스 수를 세는 방법은, 각 팀의 패스를 직접 카운트 하는 사람이 있다고 한다.
이는 아마추어 경기에서는 사실상 이뤄지기 어려운 작업이라고 생각했고, 이를 자동화하여 유의미한 피드백을 주고 싶었던 것이 프로젝트의 목적이다.

그러나 1대1 간의 정확한 패스 수 카운트 구현을 마쳤음에도, 드론으로 전체적인 경기장을 찍은 2차원 영상 위에서는 다양한 이유로 정확한 패스를 감지해낼 수 없었다. 이는 패스수를 통해 점유율을 감지하는 것이 유의미한 숫자가 나올 수 없을 것이라는 판단에 이르렀다.

그래서 그 대안으로 각 팀이 볼을 소유한 시간을 기준으로 소유율을 나타내고자 새로 정의하였다.

⭐ 우리가 정의한 볼 소유율
각 팀이 볼을 소유한 시간의 비율
팀 볼 소유 시간 / (팀 볼 소유 시간 + 상대팀 볼 소유 시간)

📌 데이터셋 학습시키기

유튜브에 나와있는 적절한 영상을 이용해 [yello_team, red_team, ball]의 데이터셋을 학습시켰다. 그 과정은 이전 졸업 프로젝트 기술 블로그와 같으니 생략하고 넘어가겠다.
[Deep Learning] Yolov5를 이용한 선수, 공 추적 프로젝트

출처 : https://www.youtube.com/watch?v=i_Wdb9Qd7Cc


📌 볼 소유율 구현

📍 팀 클래스, 공 클래스

class Team:
    def __init__(self):
        self.location_list = [] # 매 프레임 팀 좌표 리스트
        self.all_location_list = [] # 경기중 모든 팀 좌표 리스트
        
    def setLocation(self, list):
        self.location_list.append(list) # 선수의 좌표 추가

    def resetLocation(self):
        self.location_list = [] # 좌표 리셋

    # 저장해놓은 바운딩 박스 좌표 얻기
    def getLeftX(self, index):
        return self.location_list[index][0]

    def getRightX(self, index):
        return self.location_list[index][2]

    def getUpY(self, index):
        return self.location_list[index][3] 

    def getDownY(self, index):
        return self.location_list[index][1] # 바운딩 박스 왼쪽 X 좌표 얻기
        
    def callLocation(self, index):
        return self.location_list[index]

    def setAllLocation(self, list):
        self.all_location_list.append(list)

    def callAllLocation(self):
        return self.all_location_list

class Ball:          
    def __init__(self):
        self.location = []
                            
    def setLocation(self, list):
        self.location.append(list) # 현재 위치

    def resetLocation(self):
        self.location = [] # 좌표 리셋
    
    def getLeftX(self):
        return self.location[0][0]

    def getRightX(self):
        return self.location[0][2]

    def getUpY(self):
        return self.location[0][3]

    def getDownY(self):
        return self.location[0][1]

    def printLocation(self):
        print(self.location)

team1 = Team()
team2 = Team()
ball = Ball()

nowTeam1 = "" # 현재 공을 가졌다고 판단되었는지 true or ""
nowTeam2 = ""

nowTeam = "" # 현재 공을 소유한 팀

team1_possession = 0 # 소유율
team2_possession = 0

이렇게 TeamBall을 정의해서 사용했다.

📍 탐지된 객체 좌표 얻기

아래는 yolov5에 기본적으로 있는 detection 코드이다.

 # Process detections
        for i, det in enumerate(pred):  # detections per image
            seen += 1
            if webcam:  # nr_sources >= 1
                p, im0, _ = path[i], im0s[i].copy(), dataset.count
                p = Path(p)  # to Path
                s += f'{i}: '
                txt_file_name = p.name
                save_path = str(save_dir / p.name)  # im.jpg, vid.mp4, ...
            else:
                p, im0, _ = path, im0s.copy(), getattr(dataset, 'frame', 0)
                p = Path(p)  # to Path
                # video file
                if source.endswith(VID_FORMATS):
                    txt_file_name = p.stem
                    save_path = str(save_dir / p.name)  # im.jpg, vid.mp4, ...
                # folder with imgs
                else:
                    txt_file_name = p.parent.name  # get folder name containing current img
                    save_path = str(save_dir / p.parent.name)  # im.jpg, vid.mp4, ...
            curr_frames[i] = im0

            txt_path = str(save_dir / 'tracks' / txt_file_name)  # im.txt
            s += '%gx%g ' % im.shape[2:]  # print string
            imc = im0.copy() if save_crop else im0  # for save_crop

            annotator = Annotator(im0, line_width=line_thickness, example=str(names))
            
            if hasattr(tracker_list[i], 'tracker') and hasattr(tracker_list[i].tracker, 'camera_update'):
                if prev_frames[i] is not None and curr_frames[i] is not None:  # camera motion compensation
                    tracker_list[i].tracker.camera_update(prev_frames[i], curr_frames[i])

            

💻 아래는 내가 구현한 코드이다.
여기서 매 프레임마다 각 팀 선수와 공 좌표를 얻어서 아까 선언했던 객체에 넣어주는 작업을 했다.

			if det is not None and len(det):
                for c in det:
                    cls = int(c[5]) # 클래스
         
                    location = []                      
                    location.append(int(c[0])) # 왼쪽 x
                    location.append(int(c[1])) # 위쪽 y
                    location.append(int(c[2])) # 오른쪽 x
                    location.append(int(c[3])) # 아래쪽 y

                    # yello team일 때
                    if(cls == 0):
                        team1.setLocation(location)
                    # red team일 때
                    elif(cls == 1):
                        team2.setLocation(location)
                    # ball일 때
                    elif(cls == 2):
                        ball.setLocation(location)

📍 볼 소유 탐지

아래는 얻어온 좌표를 이용해 볼 소유를 탐지하는 구현이다.

먼저 저장해둔 공의 좌표를 가져왔다.
가끔 인식 오류로 인해 인식한 공이 없거나 두개 이상인 경우에는 취급하지 않고 볼 소유를 탐지하지 않도록 했다

# 인식한 공이 하나일 때만 취급
    if(len(ball.location) == 1):
        # 저장해둔 공 좌표 얻기
        B_LeftX = ball.getLeftX()
        B_RightX = ball.getRightX()
        B_UpY = ball.getUpY()
        B_DownY = ball.getDownY()
        
        # 볼 중앙 좌표 얻기
        Ball_X = (B_LeftX + B_RightX) / 2
        Ball_Y = (B_UpY + B_DownY) / 2
        
        # 볼 바운딩 박스 넓이 얻기
        ballWidth = B_RightX - B_LeftX
        boundary = ballWidth

다음 각 팀 선수들의 좌표와 공의 좌표를 비교해서 볼 소유를 감지했다.

얻어놓은 볼 넓이를 boundary값으로 설정해 각 팀 선수들의 바운딩박스보다 boundary값만큼 더 확장된 영역에서 ball의 좌표가 겹쳐지는 경우 볼 소유로 판단했다.
그림으로 표현하면 다음과 같다.

볼 소유가 확정된 경우 히트맵 구현을 위해 볼을 소유한 선수의 좌표를 누적으로 저장시킨다.

# yellow team 선수들의 좌표와 공의 좌표 비교
for index_1 in range(0,len(team1.location_list)):
      # 선수 좌표 얻기
      LeftX = team1.getLeftX(index_1)
            RightX = team1.getRightX(index_1)
      UpY = team1.getUpY(index_1)
      DownY = team1.getDownY(index_1)
       # 선수가 공을 가졌는지 판단
          if(LeftX-boundary < Ball_X and Ball_X < RightX+boundary) and (DownY-boundary < Ball_Y and Ball_Y < UpY+boundary):
              nowTeam1 = "true"
              nowTeam = "team1"
              team1.setAllLocation([LeftX, UpY, RightX, DownY]) # 좌표 저장

# red team 선수들의 좌표와 공의 좌표 비교
### ...yello team과 같음...

📍 볼 소유 계산

볼 소유 판단이 끝난 후에는 해당 팀의 소유를 1 증가시킨다.

if(not(nowTeam1 == "true" and nowTeam2 == "true") and (nowTeam1 == "true" or nowTeam2 == "true")):
    if(nowTeam == "team1"):
        team1_possession +=1
    elif(nowTeam == "team2"):
        team2_possession +=1

📍 좌표 리셋

매 프레임 볼 소유 탐지가 끝난 후에는 모든 좌표를 리셋해주었다.

    # 공과 팀 선수들의 좌표 리셋
    team1.resetLocation()
    team2.resetLocation()
    ball.resetLocation()
    nowTeam1 = ""
    nowTeam2 = ""
    nowTeam = ""

📌 최종 결과 및 히트맵 구현

볼을 소유한 팀의 좌표를 저장해둔 배열을 이용해 각 팀의 heatmap을 구현한다.
heatmap 구현은 다른 팀원이 해주었고, 나는 그 함수를 불러 사용하는 식으로 연결했다.

    print("team1_possession result : ", team1_possession)
    print("team2_possession result : ", team2_possession)
    HF.heatmap("team1", team1.callAllLocation()) # team1 히트맵
    HF.heatmap("team2", team2.callAllLocation()) # team2 히트맵

📌 실행

해당 코드들을 깃허브에 올려놓았고, 텐센트 클라우드의 가상환경에서 돌릴 수 있도록 연결해 놓았다.
실행 명령어는 다음과 같다.

python trackPass.py --yolo-weight {학습시킨 모델} --source {영상 경로}

📌 결과

팀원들과 직접 소유 시간을 측정해보았고, 이를 자동 분석 결과와 비교해보았다.

자동 분석
team1 소유율 : 31%
team2 소유율 : 69%

수동 분석
team1 소유율 : 35%
team2 소유율 : 65%

😀!! 예상했던 것보다 높은 정확도가 나와서 놀랐다.
이를 통해 유의미한 결과를 낼 수 있다는 결론을 얻었다.


마치며

처음 다뤄보는 컴퓨터 비전, 딥러닝에서 데이터 라벨링과 학습시키는 것부터, object detection을 하고 그 정보를 받아와 유의미한 정보를 얻어내는 과정이 쉽지만은 않았다. 원했던 구현을 하기 위한 레퍼런스도 많지 않았기에, 직접 구현을 해보면서 하나 하나 디벨롭해가는 과정이 길었다.

또, 아쉬운 점이 있다면 2D 상에서 이 모든 것을 구현해야 했기에, 구현할 수 있는 것에 대한 제약이 많았다. 그렇지만 "아마추어"를 위한 서비스로 "가성비"가 좋고, 필요한 핵심만 간단히 얻어낼 수 있는 것에 집중하고자 하여 최대한 유의미한 결과를 뽑아내고자 노력했다.

제대로 다뤄본 적이 없었던 딥러닝을 이렇게 진득하게 다뤄보면서, 재밌기도 했고 또 딥러닝 모델에 대한 이해도 해볼 수 있었던 유익한 시간이었던 것 같다.

0개의 댓글