yugangee commited on
Commit
493a0b0
·
verified ·
1 Parent(s): 0c22bb1

Update trackers/tracker.py

Browse files
Files changed (1) hide show
  1. trackers/tracker.py +372 -364
trackers/tracker.py CHANGED
@@ -1,364 +1,372 @@
1
- from ultralytics import YOLO
2
- import supervision as sv
3
- import pickle
4
- import os
5
- import numpy as np
6
- import pandas as pd
7
- import cv2
8
- from collections import defaultdict
9
- from utils import get_center_of_bbox, get_bbox_width
10
- from PIL import ImageFont, ImageDraw, Image
11
-
12
-
13
- from deep_sort_realtime.deepsort_tracker import DeepSort
14
-
15
-
16
- class Tracker:
17
- def __init__(self, model_path):
18
- self.model = YOLO(model_path)
19
- # DeepSORT 초기화
20
- self.tracker = DeepSort(max_age=30,
21
- n_init=3,
22
- nms_max_overlap=1.0,
23
- max_cosine_distance=0.2,
24
- nn_budget=None,
25
- override_track_class=None,
26
- embedder="mobilenet",
27
- half=True,
28
- bgr=True)
29
- self.previous_ball_owner = None
30
- self.previous_team = None
31
- self.commentary = ""
32
- self.frame_count = 0
33
-
34
- # 예전처럼 NanumGothic 사용
35
- self.default_font = ImageFont.truetype("fonts/NanumGothic.ttf", 32)
36
-
37
-
38
- def draw_text_centered(self, frame, text, font_size=32, y_offset=150, font_path="fonts/NanumGothic.ttf"):
39
- if not text:
40
- return frame
41
-
42
- text = text.replace('"', '').replace("\n", " ") # 한 줄로
43
-
44
- # 폰트 텍스트 크기
45
- font = self.default_font
46
- bbox = font.getbbox(text)
47
- text_width = bbox[2] - bbox[0]
48
- text_height = bbox[3] - bbox[1]
49
-
50
- img_height, img_width, _ = frame.shape
51
- x = (img_width - text_width) // 2
52
- y = img_height - y_offset
53
-
54
- # 그리기
55
- img_pil = Image.fromarray(frame)
56
- draw = ImageDraw.Draw(img_pil)
57
- draw.text((x, y), text, font=font, fill=(255, 255, 255))
58
-
59
- return np.asarray(img_pil)
60
-
61
-
62
- def add_positions_to_tracks(self, tracks):
63
- for current_frame in range(len(tracks['players'])):
64
- for object_type, object_tracks in tracks.items():
65
- if current_frame < len(object_tracks):
66
- frame_tracks = object_tracks[current_frame]
67
- if isinstance(frame_tracks, dict):
68
- for track_id, track_info in frame_tracks.items():
69
- bbox = track_info['bbox']
70
- position = get_center_of_bbox(bbox)
71
- track_info['position'] = position
72
-
73
- def interpolate_ball_positions(self, ball_positions):
74
- ball_positions = [x.get(1, {}).get('bbox', [np.nan]*4) for x in ball_positions]
75
- df_ball_positions = pd.DataFrame(ball_positions, columns=['x1', 'y1', 'x2', 'y2'])
76
- df_ball_positions = df_ball_positions.interpolate().bfill()
77
- ball_positions = [{1: {"bbox": x.tolist()}} for x in df_ball_positions.to_numpy()]
78
- return ball_positions
79
-
80
- def detect_frames(self, frames):
81
- batch_size = 20
82
- detections = []
83
- for i in range(0, len(frames), batch_size):
84
- detections_batch = self.model.predict(frames[i:i + batch_size], conf=0.1)
85
- detections += detections_batch
86
- return detections
87
-
88
- def get_object_tracks(self, frames, read_from_stub=False, stub_path=None):
89
- if read_from_stub and stub_path and os.path.exists(stub_path):
90
- with open(stub_path, 'rb') as f:
91
- tracks = pickle.load(f)
92
- return tracks
93
-
94
- detections = self.detect_frames(frames)
95
- tracks = {
96
- "players": [],
97
- "referees": [],
98
- "ball": []
99
- }
100
-
101
- for frame_num, detection in enumerate(detections):
102
- cls_names = detection.names
103
- cls_names_inv = {v: k for k, v in cls_names.items()}
104
- detection_supervision = sv.Detections.from_ultralytics(detection)
105
-
106
- # 골키퍼를 플레이어로 통합
107
- for idx, class_id in enumerate(detection_supervision.class_id):
108
- if cls_names[class_id] == "goalkeeper":
109
- detection_supervision.class_id[idx] = cls_names_inv["player"]
110
-
111
- # 현재 프레임의 감지 결과를 저장할 딕셔너리
112
- current_frame_tracks = {
113
- "players": {},
114
- "referees": {},
115
- "ball": {}
116
- }
117
-
118
- # 각 감지된 객체에 대해 처리
119
- for idx in range(len(detection_supervision.xyxy)):
120
- bbox = detection_supervision.xyxy[idx].tolist()
121
- cls_id = detection_supervision.class_id[idx]
122
- cls_name = cls_names[cls_id]
123
- position = get_center_of_bbox(bbox)
124
-
125
- # 이전 프레임의 트랙들과 비교하여 매칭
126
- matched_track_id = self.match_with_previous_tracks(position, cls_name)
127
-
128
- # 매칭된 트랙 ID가 없으면 새로운 트랙으로 추가
129
- if matched_track_id is None:
130
- matched_track_id = self.next_track_id
131
- self.next_track_id += 1
132
-
133
- # 트랙 히스토리 업데이트
134
- self.track_history[matched_track_id] = {
135
- "position": position,
136
- "bbox": bbox,
137
- "last_seen": frame_num
138
- }
139
-
140
- # 현재 프레임 트랙에 추가
141
- if cls_name == 'player':
142
- current_frame_tracks["players"][matched_track_id] = {"bbox": bbox}
143
- elif cls_name == 'referee':
144
- current_frame_tracks["referees"][matched_track_id] = {"bbox": bbox}
145
- elif cls_name == 'ball':
146
- current_frame_tracks["ball"][1] = {"bbox": bbox} # 볼은 항상 ID 1로 유지
147
-
148
- # 오래된 트랙 제거 (예: 30프레임 이상 감지되지 않은 트랙)
149
- self.remove_stale_tracks(frame_num, max_age=30)
150
-
151
- # 트랙 결과 저장
152
- tracks["players"].append(current_frame_tracks["players"])
153
- tracks["referees"].append(current_frame_tracks["referees"])
154
- tracks["ball"].append(current_frame_tracks["ball"])
155
-
156
- if stub_path:
157
- with open(stub_path, 'wb') as f:
158
- pickle.dump(tracks, f)
159
-
160
- return tracks
161
-
162
- def match_with_previous_tracks(self, position, cls_name, max_distance=50):
163
- """
164
- 현재 감지된 객체를 이전 트랙들과 비교하여 매칭
165
- """
166
- min_distance = float('inf')
167
- matched_track_id = None
168
-
169
- for track_id, track_info in self.track_history.items():
170
- if track_info.get('class_name') != cls_name:
171
- continue
172
- prev_position = track_info['position']
173
- distance = np.linalg.norm(np.array(position) - np.array(prev_position))
174
- if distance < min_distance and distance < max_distance:
175
- min_distance = distance
176
- matched_track_id = track_id
177
-
178
- return matched_track_id
179
-
180
- def remove_stale_tracks(self, current_frame_num, max_age=30):
181
- """
182
- 오래된 트랙을 제거하여 메모리 관리 트랙 ID 재사용 방지
183
- """
184
- stale_track_ids = []
185
- for track_id, track_info in self.track_history.items():
186
- if current_frame_num - track_info['last_seen'] > max_age:
187
- stale_track_ids.append(track_id)
188
- for track_id in stale_track_ids:
189
- del self.track_history[track_id]
190
-
191
- def update_ball_owner(self, player_id, team_id):
192
- if self.previous_ball_owner is None:
193
- self.commentary = f"Player {player_id} has the ball."
194
- elif player_id != self.previous_ball_owner:
195
- if team_id != self.previous_team:
196
- self.commentary = f"플레이어 {self.previous_ball_owner}이 공을 뺏겼습니다. 플레이어 {player_id}가 공을 소유중입니다.\n Player {self.previous_ball_owner} lost the ball. Player {player_id} now has it."
197
- else:
198
- self.commentary = f"플레이어 {self.previous_ball_owner}가 플레이어 {player_id}에게 패스를 하였습니다.\n Player {self.previous_ball_owner} passed the ball to {player_id}."
199
- self.previous_ball_owner = player_id
200
- self.previous_team = team_id
201
-
202
- def draw_text(self, frame, text, y_position, font_size=32, color=(255, 255, 255), font_path="fonts/NotoSansCJKkr-Regular.ttf"):
203
- img_pil = Image.fromarray(frame)
204
- draw = ImageDraw.Draw(img_pil)
205
-
206
- try:
207
- font = ImageFont.truetype(font_path, font_size)
208
- except OSError:
209
- print(f"⚠️ 폰트 로드 실패: {font_path} → 기본 폰트로 대체")
210
- font = ImageFont.load_default()
211
-
212
- # 깨지는 문자 제거 (선택)
213
- text = text.replace('"', '')
214
-
215
- text_bbox = draw.textbbox((0, 0), text, font=font)
216
- text_width = text_bbox[2] - text_bbox[0]
217
- x_position = (frame.shape[1] - text_width) // 2
218
- draw.text((x_position, y_position), text, font=font, fill=color)
219
-
220
- return np.array(img_pil)
221
-
222
- def draw_annotations(self, video_frames, tracks, team_ball_control, subtitle_texts, event_texts):
223
- output_video_frames = []
224
- for frame_num, frame in enumerate(video_frames):
225
- frame = frame.copy()
226
-
227
- player_dict = tracks["players"][frame_num]
228
- ball_dict = tracks["ball"][frame_num]
229
- referee_dict = tracks["referees"][frame_num]
230
-
231
- # 플레이어 그리기
232
- for track_id, player in player_dict.items():
233
- color = player.get("team_color", (0, 0, 255))
234
- frame = self.draw_ellipse(frame, player["bbox"], color, track_id)
235
-
236
- if player.get('has_ball', False):
237
- frame = self.draw_triangle(frame, player["bbox"], (0, 0, 255))
238
-
239
- # 속도 표시
240
- speed = player.get('speed', 0)
241
- position = player.get('position', (0, 0))
242
-
243
- # 심판 그리기
244
- for track_id, referee in referee_dict.items():
245
- frame = self.draw_ellipse(frame, referee["bbox"], (0, 255, 255), track_id)
246
-
247
- # 그리기
248
- for track_id, ball in ball_dict.items():
249
- frame = self.draw_triangle(frame, ball["bbox"], (0, 255, 0))
250
-
251
- # 볼 컨트롤 그리기
252
- frame = self.draw_team_ball_control(frame, frame_num, np.array(team_ball_control))
253
-
254
- # 자막 (GPT 생성) 먼저 그리기 - 위쪽 위치
255
- frame = self.draw_subtitle(frame, subtitle_texts[frame_num] if frame_num < len(subtitle_texts) else "")
256
-
257
- # 이벤트 자막은 아래쪽 위치
258
- frame = self.draw_event_subtitle(frame, event_texts[frame_num] if frame_num < len(event_texts) else "")
259
-
260
-
261
- output_video_frames.append(frame)
262
-
263
- return output_video_frames
264
-
265
- def draw_subtitle(self, frame, subtitle_text):
266
- return self.draw_text_centered(frame, subtitle_text, y_offset=160)
267
-
268
- def draw_event_subtitle(self, frame, event_text):
269
- return self.draw_text_centered(frame, event_text, y_offset=100)
270
-
271
-
272
- def draw_triangle(self, frame, bbox, color):
273
- y = int(bbox[1])
274
- x, _ = get_center_of_bbox(bbox)
275
-
276
- triangle_points = np.array([
277
- [x, y],
278
- [x - 10, y - 20],
279
- [x + 10, y - 20],
280
- ])
281
- cv2.drawContours(frame, [triangle_points], 0, color, cv2.FILLED)
282
- cv2.drawContours(frame, [triangle_points], 0, (0, 0, 0), 2)
283
-
284
- return frame
285
-
286
- def draw_ellipse(self, frame, bbox, color, track_id=None):
287
- y2 = int(bbox[3])
288
- x_center, _ = get_center_of_bbox(bbox)
289
- width = get_bbox_width(bbox)
290
-
291
- cv2.ellipse(
292
- frame,
293
- center=(x_center, y2),
294
- axes=(int(width), int(0.35 * width)),
295
- angle=0.0,
296
- startAngle=-45,
297
- endAngle=235,
298
- color=color,
299
- thickness=2,
300
- lineType=cv2.LINE_4
301
- )
302
-
303
- rectangle_width = 40
304
- rectangle_height = 20
305
- x1_rect = x_center - rectangle_width // 2
306
- x2_rect = x_center + rectangle_width // 2
307
- y1_rect = (y2 - rectangle_height // 2) + 15
308
- y2_rect = (y2 + rectangle_height // 2) + 15
309
-
310
- if track_id is not None:
311
- cv2.rectangle(frame,
312
- (int(x1_rect), int(y1_rect)),
313
- (int(x2_rect), int(y2_rect)),
314
- color,
315
- cv2.FILLED)
316
-
317
- x1_text = x1_rect + 12
318
- if track_id > 99:
319
- x1_text -= 10
320
-
321
- cv2.putText(
322
- frame,
323
- f"{track_id}",
324
- (int(x1_text), int(y1_rect + 15)),
325
- cv2.FONT_HERSHEY_SIMPLEX,
326
- 0.6,
327
- (0, 0, 0),
328
- 2
329
- )
330
-
331
- return frame
332
-
333
- def draw_team_ball_control(self, frame, frame_num, team_ball_control, FPS=30):
334
- # 반투명한 배경 박스 (왼쪽 상단)
335
- overlay = frame.copy()
336
- cv2.rectangle(overlay, (30, 30), (500, 110), (255, 255, 255), -1)
337
- alpha = 0.4
338
- cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame)
339
-
340
- # 현재 프레임까지의 팀 점유 데이터
341
- team_ball_control_till_frame = team_ball_control[:frame_num + 1]
342
-
343
- # 0(소유 불확실) 제외
344
- valid_frames = team_ball_control_till_frame[team_ball_control_till_frame > 0]
345
-
346
- # 아직 소유 팀이 잡혔으면 0% 표시
347
- if len(valid_frames) == 0:
348
- team_1_ratio = team_2_ratio = 0
349
- else:
350
- # 팀별 프레임 수
351
- team_1_frames = np.sum(valid_frames == 1)
352
- team_2_frames = np.sum(valid_frames == 2)
353
-
354
- total_frames = team_1_frames + team_2_frames
355
- team_1_ratio = team_1_frames / total_frames
356
- team_2_ratio = team_2_frames / total_frames
357
-
358
- # 점유율 표시
359
- cv2.putText(frame, f"Team 1 Ball Control: {team_1_ratio * 100:.1f}%", (50, 70),
360
- cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
361
- cv2.putText(frame, f"Team 2 Ball Control: {team_2_ratio * 100:.1f}%", (50, 100),
362
- cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
363
-
364
- return frame
 
 
 
 
 
 
 
 
 
1
+ from ultralytics import YOLO
2
+ import torch
3
+ import supervision as sv
4
+ import pickle
5
+ import os
6
+ import numpy as np
7
+ import pandas as pd
8
+ import cv2
9
+ from collections import defaultdict
10
+ from utils import get_center_of_bbox, get_bbox_width
11
+ from PIL import ImageFont, ImageDraw, Image
12
+
13
+ from deep_sort_realtime.deepsort_tracker import DeepSort
14
+
15
+
16
+ class Tracker:
17
+ def __init__(self, model_path):
18
+ # YOLO 모델 로드
19
+ self.model = YOLO(model_path)
20
+
21
+ # GPU가 가능하면 GPU로, 없으면 CPU로 이동 후 FP16 모드 적용 (메모리 절약)
22
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
23
+ self.model.to(device).half()
24
+
25
+ # DeepSORT 초기화
26
+ self.tracker = DeepSort(
27
+ max_age=30,
28
+ n_init=3,
29
+ nms_max_overlap=1.0,
30
+ max_cosine_distance=0.2,
31
+ nn_budget=None,
32
+ override_track_class=None,
33
+ embedder="mobilenet",
34
+ half=True,
35
+ bgr=True
36
+ )
37
+
38
+ self.previous_ball_owner = None
39
+ self.previous_team = None
40
+ self.commentary = ""
41
+ self.frame_count = 0
42
+
43
+ # 예전처럼 NanumGothic 사용
44
+ self.default_font = ImageFont.truetype("fonts/NanumGothic.ttf", 32)
45
+
46
+ def draw_text_centered(self, frame, text, font_size=32, y_offset=150, font_path="fonts/NanumGothic.ttf"):
47
+ if not text:
48
+ return frame
49
+
50
+ text = text.replace('"', '').replace("\n", " ") # 한 줄로
51
+
52
+ # 폰트 텍스트 크기
53
+ font = self.default_font
54
+ bbox = font.getbbox(text)
55
+ text_width = bbox[2] - bbox[0]
56
+ text_height = bbox[3] - bbox[1]
57
+
58
+ img_height, img_width, _ = frame.shape
59
+ x = (img_width - text_width) // 2
60
+ y = img_height - y_offset
61
+
62
+ # 그리기
63
+ img_pil = Image.fromarray(frame)
64
+ draw = ImageDraw.Draw(img_pil)
65
+ draw.text((x, y), text, font=font, fill=(255, 255, 255))
66
+
67
+ return np.asarray(img_pil)
68
+
69
+
70
+ def add_positions_to_tracks(self, tracks):
71
+ for current_frame in range(len(tracks['players'])):
72
+ for object_type, object_tracks in tracks.items():
73
+ if current_frame < len(object_tracks):
74
+ frame_tracks = object_tracks[current_frame]
75
+ if isinstance(frame_tracks, dict):
76
+ for track_id, track_info in frame_tracks.items():
77
+ bbox = track_info['bbox']
78
+ position = get_center_of_bbox(bbox)
79
+ track_info['position'] = position
80
+
81
+ def interpolate_ball_positions(self, ball_positions):
82
+ ball_positions = [x.get(1, {}).get('bbox', [np.nan]*4) for x in ball_positions]
83
+ df_ball_positions = pd.DataFrame(ball_positions, columns=['x1', 'y1', 'x2', 'y2'])
84
+ df_ball_positions = df_ball_positions.interpolate().bfill()
85
+ ball_positions = [{1: {"bbox": x.tolist()}} for x in df_ball_positions.to_numpy()]
86
+ return ball_positions
87
+
88
+ def detect_frames(self, frames):
89
+ batch_size = 20
90
+ detections = []
91
+ for i in range(0, len(frames), batch_size):
92
+ detections_batch = self.model.predict(frames[i:i + batch_size], conf=0.1)
93
+ detections += detections_batch
94
+ return detections
95
+
96
+ def get_object_tracks(self, frames, read_from_stub=False, stub_path=None):
97
+ if read_from_stub and stub_path and os.path.exists(stub_path):
98
+ with open(stub_path, 'rb') as f:
99
+ tracks = pickle.load(f)
100
+ return tracks
101
+
102
+ detections = self.detect_frames(frames)
103
+ tracks = {
104
+ "players": [],
105
+ "referees": [],
106
+ "ball": []
107
+ }
108
+
109
+ for frame_num, detection in enumerate(detections):
110
+ cls_names = detection.names
111
+ cls_names_inv = {v: k for k, v in cls_names.items()}
112
+ detection_supervision = sv.Detections.from_ultralytics(detection)
113
+
114
+ # 골키퍼를 플레이어로 통합
115
+ for idx, class_id in enumerate(detection_supervision.class_id):
116
+ if cls_names[class_id] == "goalkeeper":
117
+ detection_supervision.class_id[idx] = cls_names_inv["player"]
118
+
119
+ # 현재 프레임의 감지 결과를 저장할 딕셔너리
120
+ current_frame_tracks = {
121
+ "players": {},
122
+ "referees": {},
123
+ "ball": {}
124
+ }
125
+
126
+ # 감지된 객체에 대해 처리
127
+ for idx in range(len(detection_supervision.xyxy)):
128
+ bbox = detection_supervision.xyxy[idx].tolist()
129
+ cls_id = detection_supervision.class_id[idx]
130
+ cls_name = cls_names[cls_id]
131
+ position = get_center_of_bbox(bbox)
132
+
133
+ # 이전 프레임의 트랙들과 비교하여 매칭
134
+ matched_track_id = self.match_with_previous_tracks(position, cls_name)
135
+
136
+ # 매칭된 트랙 ID가 없으면 새로운 트랙으로 추가
137
+ if matched_track_id is None:
138
+ matched_track_id = self.next_track_id
139
+ self.next_track_id += 1
140
+
141
+ # 트랙 히스토리 업데이트
142
+ self.track_history[matched_track_id] = {
143
+ "position": position,
144
+ "bbox": bbox,
145
+ "last_seen": frame_num
146
+ }
147
+
148
+ # 현재 프레임 트랙에 추가
149
+ if cls_name == 'player':
150
+ current_frame_tracks["players"][matched_track_id] = {"bbox": bbox}
151
+ elif cls_name == 'referee':
152
+ current_frame_tracks["referees"][matched_track_id] = {"bbox": bbox}
153
+ elif cls_name == 'ball':
154
+ current_frame_tracks["ball"][1] = {"bbox": bbox} # 볼은 항상 ID 1로 유지
155
+
156
+ # 오래된 트랙 제거 (예: 30프레임 이상 감지되지 않은 트랙)
157
+ self.remove_stale_tracks(frame_num, max_age=30)
158
+
159
+ # 트랙 결과 저장
160
+ tracks["players"].append(current_frame_tracks["players"])
161
+ tracks["referees"].append(current_frame_tracks["referees"])
162
+ tracks["ball"].append(current_frame_tracks["ball"])
163
+
164
+ if stub_path:
165
+ with open(stub_path, 'wb') as f:
166
+ pickle.dump(tracks, f)
167
+
168
+ return tracks
169
+
170
+ def match_with_previous_tracks(self, position, cls_name, max_distance=50):
171
+ """
172
+ 현재 감지된 객체를 이전 트랙들과 비교하여 매칭
173
+ """
174
+ min_distance = float('inf')
175
+ matched_track_id = None
176
+
177
+ for track_id, track_info in self.track_history.items():
178
+ if track_info.get('class_name') != cls_name:
179
+ continue
180
+ prev_position = track_info['position']
181
+ distance = np.linalg.norm(np.array(position) - np.array(prev_position))
182
+ if distance < min_distance and distance < max_distance:
183
+ min_distance = distance
184
+ matched_track_id = track_id
185
+
186
+ return matched_track_id
187
+
188
+ def remove_stale_tracks(self, current_frame_num, max_age=30):
189
+ """
190
+ 오래된 트랙을 제거하여 메모리 관리 및 트랙 ID 재사용 방지
191
+ """
192
+ stale_track_ids = []
193
+ for track_id, track_info in self.track_history.items():
194
+ if current_frame_num - track_info['last_seen'] > max_age:
195
+ stale_track_ids.append(track_id)
196
+ for track_id in stale_track_ids:
197
+ del self.track_history[track_id]
198
+
199
+ def update_ball_owner(self, player_id, team_id):
200
+ if self.previous_ball_owner is None:
201
+ self.commentary = f"Player {player_id} has the ball."
202
+ elif player_id != self.previous_ball_owner:
203
+ if team_id != self.previous_team:
204
+ self.commentary = f"플레이어 {self.previous_ball_owner}이 공을 뺏겼습니다. 플레이어 {player_id}가 공을 소유중입니다.\n Player {self.previous_ball_owner} lost the ball. Player {player_id} now has it."
205
+ else:
206
+ self.commentary = f"플레이어 {self.previous_ball_owner}가 플레이어 {player_id}에게 패스를 하였습니다.\n Player {self.previous_ball_owner} passed the ball to {player_id}."
207
+ self.previous_ball_owner = player_id
208
+ self.previous_team = team_id
209
+
210
+ def draw_text(self, frame, text, y_position, font_size=32, color=(255, 255, 255), font_path="fonts/NotoSansCJKkr-Regular.ttf"):
211
+ img_pil = Image.fromarray(frame)
212
+ draw = ImageDraw.Draw(img_pil)
213
+
214
+ try:
215
+ font = ImageFont.truetype(font_path, font_size)
216
+ except OSError:
217
+ print(f"⚠️ 폰트 로드 실패: {font_path} 기본 폰트로 대체")
218
+ font = ImageFont.load_default()
219
+
220
+ # 깨지는 문자 제거 (선택)
221
+ text = text.replace('"', '')
222
+
223
+ text_bbox = draw.textbbox((0, 0), text, font=font)
224
+ text_width = text_bbox[2] - text_bbox[0]
225
+ x_position = (frame.shape[1] - text_width) // 2
226
+ draw.text((x_position, y_position), text, font=font, fill=color)
227
+
228
+ return np.array(img_pil)
229
+
230
+ def draw_annotations(self, video_frames, tracks, team_ball_control, subtitle_texts, event_texts):
231
+ output_video_frames = []
232
+ for frame_num, frame in enumerate(video_frames):
233
+ frame = frame.copy()
234
+
235
+ player_dict = tracks["players"][frame_num]
236
+ ball_dict = tracks["ball"][frame_num]
237
+ referee_dict = tracks["referees"][frame_num]
238
+
239
+ # 플레이어 그리기
240
+ for track_id, player in player_dict.items():
241
+ color = player.get("team_color", (0, 0, 255))
242
+ frame = self.draw_ellipse(frame, player["bbox"], color, track_id)
243
+
244
+ if player.get('has_ball', False):
245
+ frame = self.draw_triangle(frame, player["bbox"], (0, 0, 255))
246
+
247
+ # 속도 표시
248
+ speed = player.get('speed', 0)
249
+ position = player.get('position', (0, 0))
250
+
251
+ # 심판 그리기
252
+ for track_id, referee in referee_dict.items():
253
+ frame = self.draw_ellipse(frame, referee["bbox"], (0, 255, 255), track_id)
254
+
255
+ # 그리기
256
+ for track_id, ball in ball_dict.items():
257
+ frame = self.draw_triangle(frame, ball["bbox"], (0, 255, 0))
258
+
259
+ # 팀 볼 컨트롤 그리기
260
+ frame = self.draw_team_ball_control(frame, frame_num, np.array(team_ball_control))
261
+
262
+ # 자막 (GPT 생성) 먼저 그리기 - 위쪽 위치
263
+ frame = self.draw_subtitle(frame, subtitle_texts[frame_num] if frame_num < len(subtitle_texts) else "")
264
+
265
+ # 이벤트 자막은 아래쪽 위치
266
+ frame = self.draw_event_subtitle(frame, event_texts[frame_num] if frame_num < len(event_texts) else "")
267
+
268
+
269
+ output_video_frames.append(frame)
270
+
271
+ return output_video_frames
272
+
273
+ def draw_subtitle(self, frame, subtitle_text):
274
+ return self.draw_text_centered(frame, subtitle_text, y_offset=160)
275
+
276
+ def draw_event_subtitle(self, frame, event_text):
277
+ return self.draw_text_centered(frame, event_text, y_offset=100)
278
+
279
+
280
+ def draw_triangle(self, frame, bbox, color):
281
+ y = int(bbox[1])
282
+ x, _ = get_center_of_bbox(bbox)
283
+
284
+ triangle_points = np.array([
285
+ [x, y],
286
+ [x - 10, y - 20],
287
+ [x + 10, y - 20],
288
+ ])
289
+ cv2.drawContours(frame, [triangle_points], 0, color, cv2.FILLED)
290
+ cv2.drawContours(frame, [triangle_points], 0, (0, 0, 0), 2)
291
+
292
+ return frame
293
+
294
+ def draw_ellipse(self, frame, bbox, color, track_id=None):
295
+ y2 = int(bbox[3])
296
+ x_center, _ = get_center_of_bbox(bbox)
297
+ width = get_bbox_width(bbox)
298
+
299
+ cv2.ellipse(
300
+ frame,
301
+ center=(x_center, y2),
302
+ axes=(int(width), int(0.35 * width)),
303
+ angle=0.0,
304
+ startAngle=-45,
305
+ endAngle=235,
306
+ color=color,
307
+ thickness=2,
308
+ lineType=cv2.LINE_4
309
+ )
310
+
311
+ rectangle_width = 40
312
+ rectangle_height = 20
313
+ x1_rect = x_center - rectangle_width // 2
314
+ x2_rect = x_center + rectangle_width // 2
315
+ y1_rect = (y2 - rectangle_height // 2) + 15
316
+ y2_rect = (y2 + rectangle_height // 2) + 15
317
+
318
+ if track_id is not None:
319
+ cv2.rectangle(frame,
320
+ (int(x1_rect), int(y1_rect)),
321
+ (int(x2_rect), int(y2_rect)),
322
+ color,
323
+ cv2.FILLED)
324
+
325
+ x1_text = x1_rect + 12
326
+ if track_id > 99:
327
+ x1_text -= 10
328
+
329
+ cv2.putText(
330
+ frame,
331
+ f"{track_id}",
332
+ (int(x1_text), int(y1_rect + 15)),
333
+ cv2.FONT_HERSHEY_SIMPLEX,
334
+ 0.6,
335
+ (0, 0, 0),
336
+ 2
337
+ )
338
+
339
+ return frame
340
+
341
+ def draw_team_ball_control(self, frame, frame_num, team_ball_control, FPS=30):
342
+ # 반투명한 배경 박스 (왼쪽 상단)
343
+ overlay = frame.copy()
344
+ cv2.rectangle(overlay, (30, 30), (500, 110), (255, 255, 255), -1)
345
+ alpha = 0.4
346
+ cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame)
347
+
348
+ # 현재 프레임까지의 점유 데이터
349
+ team_ball_control_till_frame = team_ball_control[:frame_num + 1]
350
+
351
+ # 0(소유 불확실) 제외
352
+ valid_frames = team_ball_control_till_frame[team_ball_control_till_frame > 0]
353
+
354
+ # 아직 소유 팀이 안 잡혔으면 0% 표시
355
+ if len(valid_frames) == 0:
356
+ team_1_ratio = team_2_ratio = 0
357
+ else:
358
+ # 팀별 프레임 수
359
+ team_1_frames = np.sum(valid_frames == 1)
360
+ team_2_frames = np.sum(valid_frames == 2)
361
+
362
+ total_frames = team_1_frames + team_2_frames
363
+ team_1_ratio = team_1_frames / total_frames
364
+ team_2_ratio = team_2_frames / total_frames
365
+
366
+ # 점유율 표시
367
+ cv2.putText(frame, f"Team 1 Ball Control: {team_1_ratio * 100:.1f}%", (50, 70),
368
+ cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
369
+ cv2.putText(frame, f"Team 2 Ball Control: {team_2_ratio * 100:.1f}%", (50, 100),
370
+ cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
371
+
372
+ return frame