Update app.py
Browse files
app.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import os
|
| 2 |
from collections import deque, defaultdict
|
| 3 |
-
from typing import List, Tuple, Dict
|
| 4 |
from io import BytesIO
|
| 5 |
import base64
|
| 6 |
|
|
@@ -62,6 +62,35 @@ EMBEDDINGS_PROCESSOR = AutoProcessor.from_pretrained(SIGLIP_MODEL_PATH, token=HF
|
|
| 62 |
# ==============================================
|
| 63 |
CONFIG = SoccerPitchConfiguration()
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
# ==============================================
|
| 66 |
# PLAYER PERFORMANCE TRACKING
|
| 67 |
# ==============================================
|
|
@@ -70,10 +99,10 @@ class PlayerPerformanceTracker:
|
|
| 70 |
|
| 71 |
def __init__(self, pitch_config):
|
| 72 |
self.config = pitch_config
|
| 73 |
-
self.player_positions = defaultdict(list)
|
| 74 |
-
self.player_velocities = defaultdict(list)
|
| 75 |
-
self.player_distances = defaultdict(float)
|
| 76 |
-
self.player_team = {}
|
| 77 |
self.player_stats = defaultdict(lambda: {
|
| 78 |
'frames_visible': 0,
|
| 79 |
'avg_velocity': 0,
|
|
@@ -92,22 +121,18 @@ class PlayerPerformanceTracker:
|
|
| 92 |
self.player_positions[tracker_id].append((position[0], position[1], frame))
|
| 93 |
self.player_stats[tracker_id]['frames_visible'] += 1
|
| 94 |
|
| 95 |
-
# Calculate velocity if we have previous position
|
| 96 |
if len(self.player_positions[tracker_id]) > 1:
|
| 97 |
prev_pos = np.array(self.player_positions[tracker_id][-2][:2])
|
| 98 |
curr_pos = np.array(position)
|
| 99 |
distance = np.linalg.norm(curr_pos - prev_pos)
|
| 100 |
self.player_distances[tracker_id] += distance
|
| 101 |
|
| 102 |
-
#
|
| 103 |
-
velocity = distance * 30 # cm/s
|
| 104 |
self.player_velocities[tracker_id].append(velocity)
|
| 105 |
|
| 106 |
-
# Update velocity stats
|
| 107 |
if velocity > self.player_stats[tracker_id]['max_velocity']:
|
| 108 |
self.player_stats[tracker_id]['max_velocity'] = velocity
|
| 109 |
|
| 110 |
-
# Track position zones (thirds of the pitch)
|
| 111 |
pitch_length = self.config.length
|
| 112 |
if position[0] < pitch_length / 3:
|
| 113 |
self.player_stats[tracker_id]['time_in_defensive_third'] += 1
|
|
@@ -124,7 +149,7 @@ class PlayerPerformanceTracker:
|
|
| 124 |
stats['avg_velocity'] = np.mean(self.player_velocities[tracker_id])
|
| 125 |
|
| 126 |
stats['total_distance'] = self.player_distances[tracker_id]
|
| 127 |
-
stats['total_distance_meters'] = self.player_distances[tracker_id] / 100
|
| 128 |
stats['team_id'] = self.player_team.get(tracker_id, -1)
|
| 129 |
|
| 130 |
return stats
|
|
@@ -136,7 +161,6 @@ class PlayerPerformanceTracker:
|
|
| 136 |
|
| 137 |
positions = np.array([(x, y) for x, y, _ in self.player_positions[tracker_id]])
|
| 138 |
|
| 139 |
-
# Create 2D histogram
|
| 140 |
pitch_length = self.config.length
|
| 141 |
pitch_width = self.config.width
|
| 142 |
|
|
@@ -146,10 +170,9 @@ class PlayerPerformanceTracker:
|
|
| 146 |
range=[[0, pitch_length], [0, pitch_width]]
|
| 147 |
)
|
| 148 |
|
| 149 |
-
# Apply Gaussian smoothing for better visualization
|
| 150 |
heatmap = gaussian_filter(heatmap, sigma=3)
|
| 151 |
|
| 152 |
-
return heatmap.T
|
| 153 |
|
| 154 |
def get_all_players_by_team(self) -> Dict[int, List[int]]:
|
| 155 |
"""Get all player IDs grouped by team"""
|
|
@@ -210,28 +233,22 @@ def create_player_heatmap_visualization(performance_tracker: PlayerPerformanceTr
|
|
| 210 |
pitch = draw_pitch(CONFIG)
|
| 211 |
heatmap = performance_tracker.generate_heatmap(tracker_id, resolution=150)
|
| 212 |
|
| 213 |
-
# Normalize heatmap
|
| 214 |
if heatmap.max() > 0:
|
| 215 |
heatmap = heatmap / heatmap.max()
|
| 216 |
|
| 217 |
-
|
| 218 |
-
scale = 0.1 # Same scale as pitch
|
| 219 |
padding = 50
|
| 220 |
|
| 221 |
pitch_height, pitch_width = pitch.shape[:2]
|
| 222 |
heatmap_resized = cv2.resize(heatmap, (pitch_width - 2*padding, pitch_height - 2*padding))
|
| 223 |
|
| 224 |
-
# Apply colormap (red = high activity, blue = low activity)
|
| 225 |
heatmap_colored = cv2.applyColorMap((heatmap_resized * 255).astype(np.uint8), cv2.COLORMAP_JET)
|
| 226 |
|
| 227 |
-
# Create overlay
|
| 228 |
overlay = pitch.copy()
|
| 229 |
overlay[padding:pitch_height-padding, padding:pitch_width-padding] = heatmap_colored
|
| 230 |
|
| 231 |
-
# Blend with pitch
|
| 232 |
result = cv2.addWeighted(pitch, 0.6, overlay, 0.4, 0)
|
| 233 |
|
| 234 |
-
# Add stats text
|
| 235 |
stats = performance_tracker.get_player_stats(tracker_id)
|
| 236 |
team_color = "Blue" if stats['team_id'] == 0 else "Pink"
|
| 237 |
|
|
@@ -284,28 +301,24 @@ def create_team_comparison_plot(performance_tracker: PlayerPerformanceTracker) -
|
|
| 284 |
|
| 285 |
player_labels = [f"#{pid}" for pid in player_ids]
|
| 286 |
|
| 287 |
-
# Distance covered
|
| 288 |
fig.add_trace(
|
| 289 |
go.Bar(x=player_labels, y=distances, name=team_names[team_id],
|
| 290 |
marker_color=colors[team_id], showlegend=True),
|
| 291 |
row=1, col=1
|
| 292 |
)
|
| 293 |
|
| 294 |
-
# Average speed
|
| 295 |
fig.add_trace(
|
| 296 |
go.Bar(x=player_labels, y=avg_speeds, name=team_names[team_id],
|
| 297 |
marker_color=colors[team_id], showlegend=False),
|
| 298 |
row=1, col=2
|
| 299 |
)
|
| 300 |
|
| 301 |
-
# Max speed
|
| 302 |
fig.add_trace(
|
| 303 |
go.Bar(x=player_labels, y=max_speeds, name=team_names[team_id],
|
| 304 |
marker_color=colors[team_id], showlegend=False),
|
| 305 |
row=2, col=1
|
| 306 |
)
|
| 307 |
|
| 308 |
-
# Attacking third time
|
| 309 |
fig.add_trace(
|
| 310 |
go.Bar(x=player_labels, y=attacking_time, name=team_names[team_id],
|
| 311 |
marker_color=colors[team_id], showlegend=False),
|
|
@@ -336,7 +349,6 @@ def create_combined_heatmaps(performance_tracker: PlayerPerformanceTracker) -> n
|
|
| 336 |
if team_id not in teams:
|
| 337 |
continue
|
| 338 |
|
| 339 |
-
# Combine all players from this team
|
| 340 |
combined_heatmap = np.zeros((150, 150))
|
| 341 |
for pid in teams[team_id]:
|
| 342 |
player_heatmap = performance_tracker.generate_heatmap(pid, resolution=150)
|
|
@@ -345,7 +357,6 @@ def create_combined_heatmaps(performance_tracker: PlayerPerformanceTracker) -> n
|
|
| 345 |
if combined_heatmap.max() > 0:
|
| 346 |
combined_heatmap = combined_heatmap / combined_heatmap.max()
|
| 347 |
|
| 348 |
-
# Create visualization
|
| 349 |
pitch = draw_pitch(CONFIG)
|
| 350 |
padding = 50
|
| 351 |
pitch_height, pitch_width = pitch.shape[:2]
|
|
@@ -377,6 +388,7 @@ def create_combined_heatmaps(performance_tracker: PlayerPerformanceTracker) -> n
|
|
| 377 |
# HELPER FUNCTIONS
|
| 378 |
# ==============================================
|
| 379 |
def resolve_goalkeepers_team_id(players: sv.Detections, goalkeepers: sv.Detections) -> np.ndarray:
|
|
|
|
| 380 |
if len(goalkeepers) == 0 or len(players) == 0:
|
| 381 |
return np.array([])
|
| 382 |
goalkeepers_xy = goalkeepers.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
|
|
@@ -389,18 +401,12 @@ def resolve_goalkeepers_team_id(players: sv.Detections, goalkeepers: sv.Detectio
|
|
| 389 |
])
|
| 390 |
|
| 391 |
|
| 392 |
-
def pil_image_to_data_uri(image: Image.Image) -> str:
|
| 393 |
-
buffered = BytesIO()
|
| 394 |
-
image.save(buffered, format="PNG")
|
| 395 |
-
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
| 396 |
-
return f"data:image/png;base64,{img_str}"
|
| 397 |
-
|
| 398 |
-
|
| 399 |
def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
|
| 400 |
pitch_referees_xy, ball_path=None):
|
| 401 |
"""Create game-style radar view with ball trail effect"""
|
| 402 |
annotated_frame = draw_pitch(CONFIG)
|
| 403 |
|
|
|
|
| 404 |
if ball_path is not None and len(ball_path) > 0:
|
| 405 |
valid_path = [coords for coords in ball_path if len(coords) > 0]
|
| 406 |
if len(valid_path) > 1:
|
|
@@ -417,6 +423,7 @@ def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
|
|
| 417 |
pitch=annotated_frame
|
| 418 |
)
|
| 419 |
|
|
|
|
| 420 |
if len(pitch_ball_xy) > 0:
|
| 421 |
annotated_frame = draw_points_on_pitch(
|
| 422 |
CONFIG, pitch_ball_xy,
|
|
@@ -426,6 +433,7 @@ def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
|
|
| 426 |
pitch=annotated_frame
|
| 427 |
)
|
| 428 |
|
|
|
|
| 429 |
for team_id, color_hex in zip([0, 1], ["00BFFF", "FF1493"]):
|
| 430 |
mask = players_class_id == team_id
|
| 431 |
if np.any(mask):
|
|
@@ -437,6 +445,7 @@ def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
|
|
| 437 |
pitch=annotated_frame
|
| 438 |
)
|
| 439 |
|
|
|
|
| 440 |
if len(pitch_referees_xy) > 0:
|
| 441 |
annotated_frame = draw_points_on_pitch(
|
| 442 |
CONFIG, pitch_referees_xy,
|
|
@@ -454,7 +463,7 @@ def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
|
|
| 454 |
# ==============================================
|
| 455 |
def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
| 456 |
"""
|
| 457 |
-
Complete football analysis
|
| 458 |
"""
|
| 459 |
if not video_path:
|
| 460 |
return None, None, None, None, None, "โ Please upload a video file."
|
|
@@ -462,13 +471,17 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 462 |
try:
|
| 463 |
progress(0, desc="๐ง Initializing...")
|
| 464 |
|
|
|
|
| 465 |
BALL_ID, GOALKEEPER_ID, PLAYER_ID, REFEREE_ID = 0, 1, 2, 3
|
| 466 |
-
STRIDE = 30
|
| 467 |
-
MAXLEN = 5
|
|
|
|
| 468 |
|
|
|
|
| 469 |
tracking_manager = PlayerTrackingManager(max_history=10)
|
| 470 |
performance_tracker = PlayerPerformanceTracker(CONFIG)
|
| 471 |
|
|
|
|
| 472 |
ellipse_annotator = sv.EllipseAnnotator(
|
| 473 |
color=sv.ColorPalette.from_hex(['#00BFFF', '#FF1493', '#FFD700']),
|
| 474 |
thickness=2
|
|
@@ -476,7 +489,8 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 476 |
label_annotator = sv.LabelAnnotator(
|
| 477 |
color=sv.ColorPalette.from_hex(['#00BFFF', '#FF1493', '#FFD700']),
|
| 478 |
text_color=sv.Color.from_hex('#FFFFFF'),
|
| 479 |
-
text_thickness=2
|
|
|
|
| 480 |
)
|
| 481 |
triangle_annotator = sv.TriangleAnnotator(
|
| 482 |
color=sv.Color.from_hex('#FFD700'),
|
|
@@ -484,6 +498,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 484 |
height=17
|
| 485 |
)
|
| 486 |
|
|
|
|
| 487 |
tracker = sv.ByteTrack(
|
| 488 |
track_activation_threshold=0.4,
|
| 489 |
lost_track_buffer=60,
|
|
@@ -492,6 +507,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 492 |
)
|
| 493 |
tracker.reset()
|
| 494 |
|
|
|
|
| 495 |
cap = cv2.VideoCapture(video_path)
|
| 496 |
if not cap.isOpened():
|
| 497 |
return None, None, None, None, None, f"โ Failed to open video: {video_path}"
|
|
@@ -506,10 +522,13 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 506 |
output_path = "/tmp/annotated_football.mp4"
|
| 507 |
out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
| 508 |
|
| 509 |
-
#
|
| 510 |
-
|
|
|
|
|
|
|
| 511 |
player_crops = []
|
| 512 |
frame_count = 0
|
|
|
|
| 513 |
while frame_count < min(total_frames, 300):
|
| 514 |
ret, frame = cap.read()
|
| 515 |
if not ret:
|
|
@@ -518,6 +537,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 518 |
if frame_count % STRIDE == 0:
|
| 519 |
result = CLIENT.infer(frame, model_id=PLAYER_DETECTION_MODEL_ID)
|
| 520 |
detections = sv.Detections.from_inference(result)
|
|
|
|
| 521 |
players_detections = detections[detections.class_id == PLAYER_ID]
|
| 522 |
|
| 523 |
if len(players_detections.xyxy) > 0:
|
|
@@ -531,19 +551,29 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 531 |
|
| 532 |
print(f"โ
Collected {len(player_crops)} player samples")
|
| 533 |
|
| 534 |
-
#
|
| 535 |
-
|
|
|
|
|
|
|
| 536 |
team_classifier = TeamClassifier(device=DEVICE)
|
| 537 |
team_classifier.fit(player_crops)
|
| 538 |
print("โ
Team classifier trained")
|
| 539 |
|
| 540 |
-
#
|
|
|
|
|
|
|
| 541 |
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
| 542 |
frame_count = 0
|
| 543 |
-
M = deque(maxlen=MAXLEN)
|
| 544 |
ball_path_raw = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
-
progress(0.2, desc="๐ฌ Processing video frames...")
|
|
|
|
| 547 |
while True:
|
| 548 |
ret, frame = cap.read()
|
| 549 |
if not ret:
|
|
@@ -553,29 +583,39 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 553 |
tracking_manager.reset_frame()
|
| 554 |
|
| 555 |
if frame_count % 30 == 0:
|
| 556 |
-
progress(0.2 + 0.
|
| 557 |
-
desc=f"๐ฌ
|
| 558 |
|
| 559 |
-
|
|
|
|
| 560 |
detections = sv.Detections.from_inference(result)
|
| 561 |
|
| 562 |
if len(detections.xyxy) == 0:
|
| 563 |
out.write(frame)
|
|
|
|
| 564 |
continue
|
| 565 |
|
|
|
|
| 566 |
ball_detections = detections[detections.class_id == BALL_ID]
|
|
|
|
|
|
|
| 567 |
all_detections = detections[detections.class_id != BALL_ID]
|
| 568 |
all_detections = all_detections.with_nms(threshold=0.5, class_agnostic=True)
|
|
|
|
|
|
|
| 569 |
all_detections = tracker.update_with_detections(detections=all_detections)
|
| 570 |
|
|
|
|
| 571 |
goalkeepers_detections = all_detections[all_detections.class_id == GOALKEEPER_ID]
|
| 572 |
players_detections = all_detections[all_detections.class_id == PLAYER_ID]
|
| 573 |
referees_detections = all_detections[all_detections.class_id == REFEREE_ID]
|
| 574 |
|
|
|
|
| 575 |
if len(players_detections.xyxy) > 0:
|
| 576 |
crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
|
| 577 |
predicted_teams = team_classifier.predict(crops)
|
| 578 |
|
|
|
|
| 579 |
for idx, tracker_id in enumerate(players_detections.tracker_id):
|
| 580 |
tracking_manager.update_team_assignment(tracker_id, predicted_teams[idx])
|
| 581 |
predicted_teams[idx] = tracking_manager.get_stable_team_id(
|
|
@@ -584,63 +624,117 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 584 |
|
| 585 |
players_detections.class_id = predicted_teams
|
| 586 |
|
|
|
|
| 587 |
goalkeepers_detections.class_id = resolve_goalkeepers_team_id(
|
| 588 |
players_detections, goalkeepers_detections
|
| 589 |
)
|
|
|
|
|
|
|
| 590 |
referees_detections.class_id -= 1
|
| 591 |
|
|
|
|
| 592 |
all_detections = sv.Detections.merge([
|
| 593 |
players_detections, goalkeepers_detections, referees_detections
|
| 594 |
])
|
|
|
|
|
|
|
| 595 |
|
|
|
|
| 596 |
labels = [f"#{tid}" for tid in all_detections.tracker_id]
|
| 597 |
|
|
|
|
| 598 |
annotated_frame = frame.copy()
|
| 599 |
annotated_frame = ellipse_annotator.annotate(annotated_frame, all_detections)
|
| 600 |
annotated_frame = label_annotator.annotate(annotated_frame, all_detections, labels=labels)
|
| 601 |
annotated_frame = triangle_annotator.annotate(annotated_frame, ball_detections)
|
| 602 |
out.write(annotated_frame)
|
| 603 |
|
| 604 |
-
#
|
|
|
|
|
|
|
| 605 |
try:
|
| 606 |
-
result_field = CLIENT.infer(frame, model_id=FIELD_DETECTION_MODEL_ID)
|
| 607 |
key_points = sv.KeyPoints.from_inference(result_field)
|
|
|
|
|
|
|
| 608 |
filter_mask = key_points.confidence[0] > 0.5
|
| 609 |
frame_ref_pts = key_points.xy[0][filter_mask]
|
| 610 |
pitch_ref_pts = np.array(CONFIG.vertices)[filter_mask]
|
| 611 |
-
transformer = ViewTransformer(source=frame_ref_pts, target=pitch_ref_pts)
|
| 612 |
-
M.append(transformer.m)
|
| 613 |
-
transformer.m = np.mean(np.array(M), axis=0)
|
| 614 |
-
|
| 615 |
-
# Track ball
|
| 616 |
-
frame_ball_xy = ball_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
|
| 617 |
-
pitch_ball_xy = transformer.transform_points(frame_ball_xy)
|
| 618 |
-
ball_path_raw.append(pitch_ball_xy)
|
| 619 |
-
|
| 620 |
-
# Track all players (including goalkeepers)
|
| 621 |
-
all_players = sv.Detections.merge([players_detections, goalkeepers_detections])
|
| 622 |
-
players_xy = all_players.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
|
| 623 |
-
pitch_players_xy = transformer.transform_points(players_xy)
|
| 624 |
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
ball_path_raw.append(np.empty((0, 2)))
|
| 635 |
|
| 636 |
cap.release()
|
| 637 |
out.release()
|
| 638 |
print(f"โ
Processed {frame_count} frames")
|
| 639 |
|
| 640 |
-
#
|
| 641 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
|
| 643 |
-
# Team comparison
|
| 644 |
comparison_fig = create_team_comparison_plot(performance_tracker)
|
| 645 |
|
| 646 |
# Combined team heatmaps
|
|
@@ -648,10 +742,11 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 648 |
team_heatmaps = create_combined_heatmaps(performance_tracker)
|
| 649 |
cv2.imwrite(team_heatmaps_path, team_heatmaps)
|
| 650 |
|
| 651 |
-
# Individual player heatmaps (top 6
|
| 652 |
progress(0.85, desc="๐บ๏ธ Creating individual heatmaps...")
|
| 653 |
teams = performance_tracker.get_all_players_by_team()
|
| 654 |
top_players = []
|
|
|
|
| 655 |
for team_id in [0, 1]:
|
| 656 |
if team_id in teams:
|
| 657 |
team_players = teams[team_id]
|
|
@@ -665,7 +760,7 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 665 |
heatmap = create_player_heatmap_visualization(performance_tracker, pid)
|
| 666 |
individual_heatmaps.append(heatmap)
|
| 667 |
|
| 668 |
-
# Arrange individual heatmaps in grid
|
| 669 |
if len(individual_heatmaps) > 0:
|
| 670 |
rows = []
|
| 671 |
for i in range(0, len(individual_heatmaps), 3):
|
|
@@ -683,29 +778,39 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 683 |
else:
|
| 684 |
individual_heatmaps_path = None
|
| 685 |
|
| 686 |
-
#
|
| 687 |
-
|
|
|
|
|
|
|
| 688 |
radar_path = "/tmp/radar_view_enhanced.png"
|
| 689 |
try:
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
|
|
|
|
|
|
|
|
|
| 698 |
except Exception as e:
|
| 699 |
print(f"โ ๏ธ Radar view creation failed: {e}")
|
| 700 |
radar_path = None
|
| 701 |
|
| 702 |
-
#
|
|
|
|
|
|
|
| 703 |
progress(0.95, desc="๐ Generating summary report...")
|
| 704 |
teams = performance_tracker.get_all_players_by_team()
|
| 705 |
|
| 706 |
summary_lines = ["โ
**Analysis Complete!**\n"]
|
| 707 |
-
summary_lines.append(f"
|
| 708 |
-
summary_lines.append(f"-
|
|
|
|
|
|
|
|
|
|
| 709 |
|
| 710 |
for team_id in [0, 1]:
|
| 711 |
if team_id not in teams:
|
|
@@ -726,17 +831,26 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 726 |
for pid in teams[team_id]]
|
| 727 |
player_distances.sort(key=lambda x: x[1], reverse=True)
|
| 728 |
|
| 729 |
-
summary_lines.append(f"\n **Top Performers:**")
|
| 730 |
for i, (pid, dist) in enumerate(player_distances[:3], 1):
|
| 731 |
stats = performance_tracker.get_player_stats(pid)
|
| 732 |
summary_lines.append(
|
| 733 |
f" {i}. Player #{pid}: {dist:.1f}m, "
|
| 734 |
-
f"Avg
|
|
|
|
| 735 |
)
|
| 736 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 737 |
summary_msg = "\n".join(summary_lines)
|
| 738 |
|
| 739 |
-
progress(1.0, desc="โ
Complete!")
|
| 740 |
|
| 741 |
return (output_path, comparison_fig, team_heatmaps_path,
|
| 742 |
individual_heatmaps_path, radar_path, summary_msg)
|
|
@@ -752,39 +866,50 @@ def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
|
| 752 |
# ==============================================
|
| 753 |
# GRADIO INTERFACE
|
| 754 |
# ==============================================
|
| 755 |
-
with gr.Blocks(title="โฝ Football Performance Analyzer") as iface:
|
| 756 |
gr.Markdown("""
|
| 757 |
# โฝ Advanced Football Video Analyzer
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
-
|
| 762 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
""")
|
| 764 |
|
| 765 |
with gr.Row():
|
| 766 |
-
video_input = gr.Video(label="Upload Football Video")
|
| 767 |
|
| 768 |
-
analyze_btn = gr.Button("๐
|
| 769 |
|
| 770 |
with gr.Row():
|
| 771 |
-
status_output = gr.Textbox(label="Analysis
|
| 772 |
|
| 773 |
with gr.Tabs():
|
| 774 |
with gr.Tab("๐น Annotated Video"):
|
| 775 |
-
|
|
|
|
| 776 |
|
| 777 |
with gr.Tab("๐ Performance Comparison"):
|
|
|
|
| 778 |
comparison_output = gr.Plot(label="Team Performance Metrics")
|
| 779 |
|
| 780 |
with gr.Tab("๐บ๏ธ Team Heatmaps"):
|
| 781 |
-
|
|
|
|
| 782 |
|
| 783 |
with gr.Tab("๐ค Individual Heatmaps"):
|
| 784 |
-
|
|
|
|
| 785 |
|
| 786 |
with gr.Tab("๐ฎ Game Radar View"):
|
| 787 |
-
|
|
|
|
| 788 |
|
| 789 |
analyze_btn.click(
|
| 790 |
fn=analyze_football_video,
|
|
@@ -801,19 +926,34 @@ with gr.Blocks(title="โฝ Football Performance Analyzer") as iface:
|
|
| 801 |
|
| 802 |
gr.Markdown("""
|
| 803 |
---
|
| 804 |
-
###
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
-
|
| 808 |
-
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
-
|
| 813 |
-
-
|
| 814 |
-
|
| 815 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 816 |
""")
|
| 817 |
|
| 818 |
if __name__ == "__main__":
|
| 819 |
-
iface.launch()
|
|
|
|
| 1 |
import os
|
| 2 |
from collections import deque, defaultdict
|
| 3 |
+
from typing import List, Tuple, Dict, Optional, Union
|
| 4 |
from io import BytesIO
|
| 5 |
import base64
|
| 6 |
|
|
|
|
| 62 |
# ==============================================
|
| 63 |
CONFIG = SoccerPitchConfiguration()
|
| 64 |
|
| 65 |
+
# ==============================================
|
| 66 |
+
# BALL PATH OUTLIER REMOVAL
|
| 67 |
+
# ==============================================
|
| 68 |
+
def replace_outliers_based_on_distance(
|
| 69 |
+
positions: List[np.ndarray],
|
| 70 |
+
distance_threshold: float
|
| 71 |
+
) -> List[np.ndarray]:
|
| 72 |
+
"""Remove outlier positions based on distance threshold"""
|
| 73 |
+
last_valid_position: Union[np.ndarray, None] = None
|
| 74 |
+
cleaned_positions: List[np.ndarray] = []
|
| 75 |
+
|
| 76 |
+
for position in positions:
|
| 77 |
+
if len(position) == 0:
|
| 78 |
+
cleaned_positions.append(position)
|
| 79 |
+
else:
|
| 80 |
+
if last_valid_position is None:
|
| 81 |
+
cleaned_positions.append(position)
|
| 82 |
+
last_valid_position = position
|
| 83 |
+
else:
|
| 84 |
+
distance = np.linalg.norm(position - last_valid_position)
|
| 85 |
+
if distance > distance_threshold:
|
| 86 |
+
cleaned_positions.append(np.array([], dtype=np.float64))
|
| 87 |
+
else:
|
| 88 |
+
cleaned_positions.append(position)
|
| 89 |
+
last_valid_position = position
|
| 90 |
+
|
| 91 |
+
return cleaned_positions
|
| 92 |
+
|
| 93 |
+
|
| 94 |
# ==============================================
|
| 95 |
# PLAYER PERFORMANCE TRACKING
|
| 96 |
# ==============================================
|
|
|
|
| 99 |
|
| 100 |
def __init__(self, pitch_config):
|
| 101 |
self.config = pitch_config
|
| 102 |
+
self.player_positions = defaultdict(list)
|
| 103 |
+
self.player_velocities = defaultdict(list)
|
| 104 |
+
self.player_distances = defaultdict(float)
|
| 105 |
+
self.player_team = {}
|
| 106 |
self.player_stats = defaultdict(lambda: {
|
| 107 |
'frames_visible': 0,
|
| 108 |
'avg_velocity': 0,
|
|
|
|
| 121 |
self.player_positions[tracker_id].append((position[0], position[1], frame))
|
| 122 |
self.player_stats[tracker_id]['frames_visible'] += 1
|
| 123 |
|
|
|
|
| 124 |
if len(self.player_positions[tracker_id]) > 1:
|
| 125 |
prev_pos = np.array(self.player_positions[tracker_id][-2][:2])
|
| 126 |
curr_pos = np.array(position)
|
| 127 |
distance = np.linalg.norm(curr_pos - prev_pos)
|
| 128 |
self.player_distances[tracker_id] += distance
|
| 129 |
|
| 130 |
+
velocity = distance * 30 # cm/s assuming 30fps
|
|
|
|
| 131 |
self.player_velocities[tracker_id].append(velocity)
|
| 132 |
|
|
|
|
| 133 |
if velocity > self.player_stats[tracker_id]['max_velocity']:
|
| 134 |
self.player_stats[tracker_id]['max_velocity'] = velocity
|
| 135 |
|
|
|
|
| 136 |
pitch_length = self.config.length
|
| 137 |
if position[0] < pitch_length / 3:
|
| 138 |
self.player_stats[tracker_id]['time_in_defensive_third'] += 1
|
|
|
|
| 149 |
stats['avg_velocity'] = np.mean(self.player_velocities[tracker_id])
|
| 150 |
|
| 151 |
stats['total_distance'] = self.player_distances[tracker_id]
|
| 152 |
+
stats['total_distance_meters'] = self.player_distances[tracker_id] / 100
|
| 153 |
stats['team_id'] = self.player_team.get(tracker_id, -1)
|
| 154 |
|
| 155 |
return stats
|
|
|
|
| 161 |
|
| 162 |
positions = np.array([(x, y) for x, y, _ in self.player_positions[tracker_id]])
|
| 163 |
|
|
|
|
| 164 |
pitch_length = self.config.length
|
| 165 |
pitch_width = self.config.width
|
| 166 |
|
|
|
|
| 170 |
range=[[0, pitch_length], [0, pitch_width]]
|
| 171 |
)
|
| 172 |
|
|
|
|
| 173 |
heatmap = gaussian_filter(heatmap, sigma=3)
|
| 174 |
|
| 175 |
+
return heatmap.T
|
| 176 |
|
| 177 |
def get_all_players_by_team(self) -> Dict[int, List[int]]:
|
| 178 |
"""Get all player IDs grouped by team"""
|
|
|
|
| 233 |
pitch = draw_pitch(CONFIG)
|
| 234 |
heatmap = performance_tracker.generate_heatmap(tracker_id, resolution=150)
|
| 235 |
|
|
|
|
| 236 |
if heatmap.max() > 0:
|
| 237 |
heatmap = heatmap / heatmap.max()
|
| 238 |
|
| 239 |
+
scale = 0.1
|
|
|
|
| 240 |
padding = 50
|
| 241 |
|
| 242 |
pitch_height, pitch_width = pitch.shape[:2]
|
| 243 |
heatmap_resized = cv2.resize(heatmap, (pitch_width - 2*padding, pitch_height - 2*padding))
|
| 244 |
|
|
|
|
| 245 |
heatmap_colored = cv2.applyColorMap((heatmap_resized * 255).astype(np.uint8), cv2.COLORMAP_JET)
|
| 246 |
|
|
|
|
| 247 |
overlay = pitch.copy()
|
| 248 |
overlay[padding:pitch_height-padding, padding:pitch_width-padding] = heatmap_colored
|
| 249 |
|
|
|
|
| 250 |
result = cv2.addWeighted(pitch, 0.6, overlay, 0.4, 0)
|
| 251 |
|
|
|
|
| 252 |
stats = performance_tracker.get_player_stats(tracker_id)
|
| 253 |
team_color = "Blue" if stats['team_id'] == 0 else "Pink"
|
| 254 |
|
|
|
|
| 301 |
|
| 302 |
player_labels = [f"#{pid}" for pid in player_ids]
|
| 303 |
|
|
|
|
| 304 |
fig.add_trace(
|
| 305 |
go.Bar(x=player_labels, y=distances, name=team_names[team_id],
|
| 306 |
marker_color=colors[team_id], showlegend=True),
|
| 307 |
row=1, col=1
|
| 308 |
)
|
| 309 |
|
|
|
|
| 310 |
fig.add_trace(
|
| 311 |
go.Bar(x=player_labels, y=avg_speeds, name=team_names[team_id],
|
| 312 |
marker_color=colors[team_id], showlegend=False),
|
| 313 |
row=1, col=2
|
| 314 |
)
|
| 315 |
|
|
|
|
| 316 |
fig.add_trace(
|
| 317 |
go.Bar(x=player_labels, y=max_speeds, name=team_names[team_id],
|
| 318 |
marker_color=colors[team_id], showlegend=False),
|
| 319 |
row=2, col=1
|
| 320 |
)
|
| 321 |
|
|
|
|
| 322 |
fig.add_trace(
|
| 323 |
go.Bar(x=player_labels, y=attacking_time, name=team_names[team_id],
|
| 324 |
marker_color=colors[team_id], showlegend=False),
|
|
|
|
| 349 |
if team_id not in teams:
|
| 350 |
continue
|
| 351 |
|
|
|
|
| 352 |
combined_heatmap = np.zeros((150, 150))
|
| 353 |
for pid in teams[team_id]:
|
| 354 |
player_heatmap = performance_tracker.generate_heatmap(pid, resolution=150)
|
|
|
|
| 357 |
if combined_heatmap.max() > 0:
|
| 358 |
combined_heatmap = combined_heatmap / combined_heatmap.max()
|
| 359 |
|
|
|
|
| 360 |
pitch = draw_pitch(CONFIG)
|
| 361 |
padding = 50
|
| 362 |
pitch_height, pitch_width = pitch.shape[:2]
|
|
|
|
| 388 |
# HELPER FUNCTIONS
|
| 389 |
# ==============================================
|
| 390 |
def resolve_goalkeepers_team_id(players: sv.Detections, goalkeepers: sv.Detections) -> np.ndarray:
|
| 391 |
+
"""Assign goalkeepers to the nearest team centroid"""
|
| 392 |
if len(goalkeepers) == 0 or len(players) == 0:
|
| 393 |
return np.array([])
|
| 394 |
goalkeepers_xy = goalkeepers.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
|
|
|
|
| 401 |
])
|
| 402 |
|
| 403 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
def create_game_style_radar(pitch_ball_xy, pitch_players_xy, players_class_id,
|
| 405 |
pitch_referees_xy, ball_path=None):
|
| 406 |
"""Create game-style radar view with ball trail effect"""
|
| 407 |
annotated_frame = draw_pitch(CONFIG)
|
| 408 |
|
| 409 |
+
# Draw ball trail with fading effect
|
| 410 |
if ball_path is not None and len(ball_path) > 0:
|
| 411 |
valid_path = [coords for coords in ball_path if len(coords) > 0]
|
| 412 |
if len(valid_path) > 1:
|
|
|
|
| 423 |
pitch=annotated_frame
|
| 424 |
)
|
| 425 |
|
| 426 |
+
# Draw current ball position
|
| 427 |
if len(pitch_ball_xy) > 0:
|
| 428 |
annotated_frame = draw_points_on_pitch(
|
| 429 |
CONFIG, pitch_ball_xy,
|
|
|
|
| 433 |
pitch=annotated_frame
|
| 434 |
)
|
| 435 |
|
| 436 |
+
# Draw players
|
| 437 |
for team_id, color_hex in zip([0, 1], ["00BFFF", "FF1493"]):
|
| 438 |
mask = players_class_id == team_id
|
| 439 |
if np.any(mask):
|
|
|
|
| 445 |
pitch=annotated_frame
|
| 446 |
)
|
| 447 |
|
| 448 |
+
# Draw referees
|
| 449 |
if len(pitch_referees_xy) > 0:
|
| 450 |
annotated_frame = draw_points_on_pitch(
|
| 451 |
CONFIG, pitch_referees_xy,
|
|
|
|
| 463 |
# ==============================================
|
| 464 |
def analyze_football_video(video_path: str, progress=gr.Progress()) -> Tuple:
|
| 465 |
"""
|
| 466 |
+
Complete football analysis pipeline following Colab notebook steps
|
| 467 |
"""
|
| 468 |
if not video_path:
|
| 469 |
return None, None, None, None, None, "โ Please upload a video file."
|
|
|
|
| 471 |
try:
|
| 472 |
progress(0, desc="๐ง Initializing...")
|
| 473 |
|
| 474 |
+
# IDs from Roboflow model
|
| 475 |
BALL_ID, GOALKEEPER_ID, PLAYER_ID, REFEREE_ID = 0, 1, 2, 3
|
| 476 |
+
STRIDE = 30 # Frame sampling for training
|
| 477 |
+
MAXLEN = 5 # Transformation matrix smoothing
|
| 478 |
+
MAX_DISTANCE_THRESHOLD = 500 # Ball path outlier threshold
|
| 479 |
|
| 480 |
+
# Initialize managers
|
| 481 |
tracking_manager = PlayerTrackingManager(max_history=10)
|
| 482 |
performance_tracker = PlayerPerformanceTracker(CONFIG)
|
| 483 |
|
| 484 |
+
# Annotators with exact colors from notebook
|
| 485 |
ellipse_annotator = sv.EllipseAnnotator(
|
| 486 |
color=sv.ColorPalette.from_hex(['#00BFFF', '#FF1493', '#FFD700']),
|
| 487 |
thickness=2
|
|
|
|
| 489 |
label_annotator = sv.LabelAnnotator(
|
| 490 |
color=sv.ColorPalette.from_hex(['#00BFFF', '#FF1493', '#FFD700']),
|
| 491 |
text_color=sv.Color.from_hex('#FFFFFF'),
|
| 492 |
+
text_thickness=2,
|
| 493 |
+
text_position=sv.Position.BOTTOM_CENTER
|
| 494 |
)
|
| 495 |
triangle_annotator = sv.TriangleAnnotator(
|
| 496 |
color=sv.Color.from_hex('#FFD700'),
|
|
|
|
| 498 |
height=17
|
| 499 |
)
|
| 500 |
|
| 501 |
+
# ByteTrack tracker with optimized settings
|
| 502 |
tracker = sv.ByteTrack(
|
| 503 |
track_activation_threshold=0.4,
|
| 504 |
lost_track_buffer=60,
|
|
|
|
| 507 |
)
|
| 508 |
tracker.reset()
|
| 509 |
|
| 510 |
+
# Video setup
|
| 511 |
cap = cv2.VideoCapture(video_path)
|
| 512 |
if not cap.isOpened():
|
| 513 |
return None, None, None, None, None, f"โ Failed to open video: {video_path}"
|
|
|
|
| 522 |
output_path = "/tmp/annotated_football.mp4"
|
| 523 |
out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
| 524 |
|
| 525 |
+
# ========================================
|
| 526 |
+
# STEP 1: Collect Player Crops for Team Classifier
|
| 527 |
+
# ========================================
|
| 528 |
+
progress(0.05, desc="๐ Collecting player samples (Step 1/6)...")
|
| 529 |
player_crops = []
|
| 530 |
frame_count = 0
|
| 531 |
+
|
| 532 |
while frame_count < min(total_frames, 300):
|
| 533 |
ret, frame = cap.read()
|
| 534 |
if not ret:
|
|
|
|
| 537 |
if frame_count % STRIDE == 0:
|
| 538 |
result = CLIENT.infer(frame, model_id=PLAYER_DETECTION_MODEL_ID)
|
| 539 |
detections = sv.Detections.from_inference(result)
|
| 540 |
+
detections = detections.with_nms(threshold=0.5, class_agnostic=True)
|
| 541 |
players_detections = detections[detections.class_id == PLAYER_ID]
|
| 542 |
|
| 543 |
if len(players_detections.xyxy) > 0:
|
|
|
|
| 551 |
|
| 552 |
print(f"โ
Collected {len(player_crops)} player samples")
|
| 553 |
|
| 554 |
+
# ========================================
|
| 555 |
+
# STEP 2: Train Team Classifier
|
| 556 |
+
# ========================================
|
| 557 |
+
progress(0.15, desc="๐ฏ Training team classifier (Step 2/6)...")
|
| 558 |
team_classifier = TeamClassifier(device=DEVICE)
|
| 559 |
team_classifier.fit(player_crops)
|
| 560 |
print("โ
Team classifier trained")
|
| 561 |
|
| 562 |
+
# ========================================
|
| 563 |
+
# STEP 3: Process Full Video with Tracking
|
| 564 |
+
# ========================================
|
| 565 |
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
| 566 |
frame_count = 0
|
| 567 |
+
M = deque(maxlen=MAXLEN) # Transformation matrix smoothing
|
| 568 |
ball_path_raw = []
|
| 569 |
+
|
| 570 |
+
# Store last frame data for radar
|
| 571 |
+
last_pitch_players_xy = None
|
| 572 |
+
last_players_class_id = None
|
| 573 |
+
last_pitch_referees_xy = None
|
| 574 |
|
| 575 |
+
progress(0.2, desc="๐ฌ Processing video frames (Step 3/6)...")
|
| 576 |
+
|
| 577 |
while True:
|
| 578 |
ret, frame = cap.read()
|
| 579 |
if not ret:
|
|
|
|
| 583 |
tracking_manager.reset_frame()
|
| 584 |
|
| 585 |
if frame_count % 30 == 0:
|
| 586 |
+
progress(0.2 + 0.4 * (frame_count / total_frames),
|
| 587 |
+
desc=f"๐ฌ Processing frame {frame_count}/{total_frames}")
|
| 588 |
|
| 589 |
+
# Player and ball detection
|
| 590 |
+
result = CLIENT.infer(frame, model_id=PLAYER_DETECTION_MODEL_ID, confidence=0.3)
|
| 591 |
detections = sv.Detections.from_inference(result)
|
| 592 |
|
| 593 |
if len(detections.xyxy) == 0:
|
| 594 |
out.write(frame)
|
| 595 |
+
ball_path_raw.append(np.empty((0, 2)))
|
| 596 |
continue
|
| 597 |
|
| 598 |
+
# Separate ball from other detections
|
| 599 |
ball_detections = detections[detections.class_id == BALL_ID]
|
| 600 |
+
ball_detections.xyxy = sv.pad_boxes(xyxy=ball_detections.xyxy, px=10)
|
| 601 |
+
|
| 602 |
all_detections = detections[detections.class_id != BALL_ID]
|
| 603 |
all_detections = all_detections.with_nms(threshold=0.5, class_agnostic=True)
|
| 604 |
+
|
| 605 |
+
# Track detections
|
| 606 |
all_detections = tracker.update_with_detections(detections=all_detections)
|
| 607 |
|
| 608 |
+
# Separate by type
|
| 609 |
goalkeepers_detections = all_detections[all_detections.class_id == GOALKEEPER_ID]
|
| 610 |
players_detections = all_detections[all_detections.class_id == PLAYER_ID]
|
| 611 |
referees_detections = all_detections[all_detections.class_id == REFEREE_ID]
|
| 612 |
|
| 613 |
+
# Team prediction with stability
|
| 614 |
if len(players_detections.xyxy) > 0:
|
| 615 |
crops = [sv.crop_image(frame, xyxy) for xyxy in players_detections.xyxy]
|
| 616 |
predicted_teams = team_classifier.predict(crops)
|
| 617 |
|
| 618 |
+
# Apply stable team assignment
|
| 619 |
for idx, tracker_id in enumerate(players_detections.tracker_id):
|
| 620 |
tracking_manager.update_team_assignment(tracker_id, predicted_teams[idx])
|
| 621 |
predicted_teams[idx] = tracking_manager.get_stable_team_id(
|
|
|
|
| 624 |
|
| 625 |
players_detections.class_id = predicted_teams
|
| 626 |
|
| 627 |
+
# Assign goalkeeper teams
|
| 628 |
goalkeepers_detections.class_id = resolve_goalkeepers_team_id(
|
| 629 |
players_detections, goalkeepers_detections
|
| 630 |
)
|
| 631 |
+
|
| 632 |
+
# Adjust referee class_id
|
| 633 |
referees_detections.class_id -= 1
|
| 634 |
|
| 635 |
+
# Merge all detections
|
| 636 |
all_detections = sv.Detections.merge([
|
| 637 |
players_detections, goalkeepers_detections, referees_detections
|
| 638 |
])
|
| 639 |
+
|
| 640 |
+
all_detections.class_id = all_detections.class_id.astype(int)
|
| 641 |
|
| 642 |
+
# Create labels
|
| 643 |
labels = [f"#{tid}" for tid in all_detections.tracker_id]
|
| 644 |
|
| 645 |
+
# Annotate frame
|
| 646 |
annotated_frame = frame.copy()
|
| 647 |
annotated_frame = ellipse_annotator.annotate(annotated_frame, all_detections)
|
| 648 |
annotated_frame = label_annotator.annotate(annotated_frame, all_detections, labels=labels)
|
| 649 |
annotated_frame = triangle_annotator.annotate(annotated_frame, ball_detections)
|
| 650 |
out.write(annotated_frame)
|
| 651 |
|
| 652 |
+
# ========================================
|
| 653 |
+
# STEP 4: Field Detection & Transformation
|
| 654 |
+
# ========================================
|
| 655 |
try:
|
| 656 |
+
result_field = CLIENT.infer(frame, model_id=FIELD_DETECTION_MODEL_ID, confidence=0.3)
|
| 657 |
key_points = sv.KeyPoints.from_inference(result_field)
|
| 658 |
+
|
| 659 |
+
# Filter confident keypoints
|
| 660 |
filter_mask = key_points.confidence[0] > 0.5
|
| 661 |
frame_ref_pts = key_points.xy[0][filter_mask]
|
| 662 |
pitch_ref_pts = np.array(CONFIG.vertices)[filter_mask]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
|
| 664 |
+
if len(frame_ref_pts) >= 4: # Need at least 4 points for homography
|
| 665 |
+
transformer = ViewTransformer(source=frame_ref_pts, target=pitch_ref_pts)
|
| 666 |
+
M.append(transformer.m)
|
| 667 |
+
transformer.m = np.mean(np.array(M), axis=0)
|
| 668 |
+
|
| 669 |
+
# Transform ball position
|
| 670 |
+
frame_ball_xy = ball_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
|
| 671 |
+
pitch_ball_xy = transformer.transform_points(frame_ball_xy)
|
| 672 |
+
ball_path_raw.append(pitch_ball_xy)
|
| 673 |
+
|
| 674 |
+
# Transform all players (including goalkeepers)
|
| 675 |
+
all_players = sv.Detections.merge([players_detections, goalkeepers_detections])
|
| 676 |
+
players_xy = all_players.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
|
| 677 |
+
pitch_players_xy = transformer.transform_points(players_xy)
|
| 678 |
+
|
| 679 |
+
# Transform referees
|
| 680 |
+
referees_xy = referees_detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
|
| 681 |
+
pitch_referees_xy = transformer.transform_points(referees_xy)
|
| 682 |
+
|
| 683 |
+
# Store for radar view
|
| 684 |
+
last_pitch_players_xy = pitch_players_xy
|
| 685 |
+
last_players_class_id = all_players.class_id
|
| 686 |
+
last_pitch_referees_xy = pitch_referees_xy
|
| 687 |
+
|
| 688 |
+
# Update performance tracker
|
| 689 |
+
for idx, tracker_id in enumerate(all_players.tracker_id):
|
| 690 |
+
if idx < len(pitch_players_xy):
|
| 691 |
+
performance_tracker.update(
|
| 692 |
+
tracker_id,
|
| 693 |
+
pitch_players_xy[idx],
|
| 694 |
+
all_players.class_id[idx],
|
| 695 |
+
frame_count
|
| 696 |
+
)
|
| 697 |
+
else:
|
| 698 |
+
ball_path_raw.append(np.empty((0, 2)))
|
| 699 |
+
except Exception as e:
|
| 700 |
ball_path_raw.append(np.empty((0, 2)))
|
| 701 |
|
| 702 |
cap.release()
|
| 703 |
out.release()
|
| 704 |
print(f"โ
Processed {frame_count} frames")
|
| 705 |
|
| 706 |
+
# ========================================
|
| 707 |
+
# STEP 5: Clean Ball Path (Remove Outliers)
|
| 708 |
+
# ========================================
|
| 709 |
+
progress(0.65, desc="๐งน Cleaning ball trajectory (Step 4/6)...")
|
| 710 |
+
|
| 711 |
+
# Convert to proper format for cleaning
|
| 712 |
+
path_for_cleaning = []
|
| 713 |
+
for coords in ball_path_raw:
|
| 714 |
+
if len(coords) == 0:
|
| 715 |
+
path_for_cleaning.append(np.empty((0, 2), dtype=np.float32))
|
| 716 |
+
elif coords.shape[0] >= 2:
|
| 717 |
+
path_for_cleaning.append(np.empty((0, 2), dtype=np.float32))
|
| 718 |
+
else:
|
| 719 |
+
path_for_cleaning.append(coords)
|
| 720 |
+
|
| 721 |
+
# Flatten coordinates
|
| 722 |
+
path_flattened = [coords.flatten() for coords in path_for_cleaning]
|
| 723 |
+
|
| 724 |
+
# Remove outliers
|
| 725 |
+
cleaned_path = replace_outliers_based_on_distance(
|
| 726 |
+
[np.array(p).reshape(-1, 2) if len(p) > 0 else np.empty((0, 2)) for p in path_for_cleaning],
|
| 727 |
+
MAX_DISTANCE_THRESHOLD
|
| 728 |
+
)
|
| 729 |
+
|
| 730 |
+
print(f"โ
Ball path cleaned: {len([p for p in cleaned_path if len(p) > 0])} valid points")
|
| 731 |
+
|
| 732 |
+
# ========================================
|
| 733 |
+
# STEP 6: Generate Performance Analytics
|
| 734 |
+
# ========================================
|
| 735 |
+
progress(0.75, desc="๐ Generating performance analytics (Step 5/6)...")
|
| 736 |
|
| 737 |
+
# Team comparison charts
|
| 738 |
comparison_fig = create_team_comparison_plot(performance_tracker)
|
| 739 |
|
| 740 |
# Combined team heatmaps
|
|
|
|
| 742 |
team_heatmaps = create_combined_heatmaps(performance_tracker)
|
| 743 |
cv2.imwrite(team_heatmaps_path, team_heatmaps)
|
| 744 |
|
| 745 |
+
# Individual player heatmaps (top 6 by distance)
|
| 746 |
progress(0.85, desc="๐บ๏ธ Creating individual heatmaps...")
|
| 747 |
teams = performance_tracker.get_all_players_by_team()
|
| 748 |
top_players = []
|
| 749 |
+
|
| 750 |
for team_id in [0, 1]:
|
| 751 |
if team_id in teams:
|
| 752 |
team_players = teams[team_id]
|
|
|
|
| 760 |
heatmap = create_player_heatmap_visualization(performance_tracker, pid)
|
| 761 |
individual_heatmaps.append(heatmap)
|
| 762 |
|
| 763 |
+
# Arrange individual heatmaps in grid (3 columns)
|
| 764 |
if len(individual_heatmaps) > 0:
|
| 765 |
rows = []
|
| 766 |
for i in range(0, len(individual_heatmaps), 3):
|
|
|
|
| 778 |
else:
|
| 779 |
individual_heatmaps_path = None
|
| 780 |
|
| 781 |
+
# ========================================
|
| 782 |
+
# STEP 7: Create Game-Style Radar View
|
| 783 |
+
# ========================================
|
| 784 |
+
progress(0.9, desc="๐บ๏ธ Creating game-style radar view (Step 6/6)...")
|
| 785 |
radar_path = "/tmp/radar_view_enhanced.png"
|
| 786 |
try:
|
| 787 |
+
if last_pitch_players_xy is not None:
|
| 788 |
+
radar_frame = create_game_style_radar(
|
| 789 |
+
pitch_ball_xy=cleaned_path[-1] if cleaned_path else np.empty((0, 2)),
|
| 790 |
+
pitch_players_xy=last_pitch_players_xy,
|
| 791 |
+
players_class_id=last_players_class_id,
|
| 792 |
+
pitch_referees_xy=last_pitch_referees_xy,
|
| 793 |
+
ball_path=cleaned_path
|
| 794 |
+
)
|
| 795 |
+
cv2.imwrite(radar_path, radar_frame)
|
| 796 |
+
else:
|
| 797 |
+
radar_path = None
|
| 798 |
except Exception as e:
|
| 799 |
print(f"โ ๏ธ Radar view creation failed: {e}")
|
| 800 |
radar_path = None
|
| 801 |
|
| 802 |
+
# ========================================
|
| 803 |
+
# Generate Summary Report
|
| 804 |
+
# ========================================
|
| 805 |
progress(0.95, desc="๐ Generating summary report...")
|
| 806 |
teams = performance_tracker.get_all_players_by_team()
|
| 807 |
|
| 808 |
summary_lines = ["โ
**Analysis Complete!**\n"]
|
| 809 |
+
summary_lines.append(f"**Video Statistics:**")
|
| 810 |
+
summary_lines.append(f"- Total Frames Processed: {frame_count}")
|
| 811 |
+
summary_lines.append(f"- Video Resolution: {width}x{height}")
|
| 812 |
+
summary_lines.append(f"- Frame Rate: {fps:.2f} fps")
|
| 813 |
+
summary_lines.append(f"- Ball Trajectory Points: {len([p for p in cleaned_path if len(p) > 0])}\n")
|
| 814 |
|
| 815 |
for team_id in [0, 1]:
|
| 816 |
if team_id not in teams:
|
|
|
|
| 831 |
for pid in teams[team_id]]
|
| 832 |
player_distances.sort(key=lambda x: x[1], reverse=True)
|
| 833 |
|
| 834 |
+
summary_lines.append(f"\n **Top 3 Performers:**")
|
| 835 |
for i, (pid, dist) in enumerate(player_distances[:3], 1):
|
| 836 |
stats = performance_tracker.get_player_stats(pid)
|
| 837 |
summary_lines.append(
|
| 838 |
f" {i}. Player #{pid}: {dist:.1f}m, "
|
| 839 |
+
f"Avg: {stats['avg_velocity']/100:.2f}m/s, "
|
| 840 |
+
f"Max: {stats['max_velocity']/100:.2f}m/s"
|
| 841 |
)
|
| 842 |
|
| 843 |
+
summary_lines.append("\n**Pipeline Steps Completed:**")
|
| 844 |
+
summary_lines.append("โ
1. Player crop collection")
|
| 845 |
+
summary_lines.append("โ
2. Team classifier training")
|
| 846 |
+
summary_lines.append("โ
3. Video processing with tracking")
|
| 847 |
+
summary_lines.append("โ
4. Ball trajectory cleaning")
|
| 848 |
+
summary_lines.append("โ
5. Performance analytics generation")
|
| 849 |
+
summary_lines.append("โ
6. Visualization creation")
|
| 850 |
+
|
| 851 |
summary_msg = "\n".join(summary_lines)
|
| 852 |
|
| 853 |
+
progress(1.0, desc="โ
Analysis Complete!")
|
| 854 |
|
| 855 |
return (output_path, comparison_fig, team_heatmaps_path,
|
| 856 |
individual_heatmaps_path, radar_path, summary_msg)
|
|
|
|
| 866 |
# ==============================================
|
| 867 |
# GRADIO INTERFACE
|
| 868 |
# ==============================================
|
| 869 |
+
with gr.Blocks(title="โฝ Football Performance Analyzer", theme=gr.themes.Soft()) as iface:
|
| 870 |
gr.Markdown("""
|
| 871 |
# โฝ Advanced Football Video Analyzer
|
| 872 |
+
### Complete Pipeline Implementation
|
| 873 |
+
|
| 874 |
+
This application follows the exact pipeline from the Colab notebook:
|
| 875 |
+
1. **Player Detection** - Collect player crops using Roboflow
|
| 876 |
+
2. **Team Classification** - Train SigLIP-based team classifier
|
| 877 |
+
3. **Persistent Tracking** - ByteTrack with stable ID assignment
|
| 878 |
+
4. **Field Transformation** - Project players onto pitch coordinates
|
| 879 |
+
5. **Ball Trajectory** - Track and clean ball path with outlier removal
|
| 880 |
+
6. **Performance Analytics** - Generate heatmaps and statistics
|
| 881 |
+
|
| 882 |
+
Upload a football match video to get comprehensive performance analytics!
|
| 883 |
""")
|
| 884 |
|
| 885 |
with gr.Row():
|
| 886 |
+
video_input = gr.Video(label="๐ค Upload Football Video")
|
| 887 |
|
| 888 |
+
analyze_btn = gr.Button("๐ Start Analysis Pipeline", variant="primary", size="lg")
|
| 889 |
|
| 890 |
with gr.Row():
|
| 891 |
+
status_output = gr.Textbox(label="๐ Analysis Summary & Statistics", lines=25)
|
| 892 |
|
| 893 |
with gr.Tabs():
|
| 894 |
with gr.Tab("๐น Annotated Video"):
|
| 895 |
+
gr.Markdown("### Full video with player tracking, team colors, and ball detection")
|
| 896 |
+
video_output = gr.Video(label="Processed Video")
|
| 897 |
|
| 898 |
with gr.Tab("๐ Performance Comparison"):
|
| 899 |
+
gr.Markdown("### Interactive charts comparing player performance metrics")
|
| 900 |
comparison_output = gr.Plot(label="Team Performance Metrics")
|
| 901 |
|
| 902 |
with gr.Tab("๐บ๏ธ Team Heatmaps"):
|
| 903 |
+
gr.Markdown("### Combined activity heatmaps showing team positioning")
|
| 904 |
+
team_heatmaps_output = gr.Image(label="Team Activity Heatmaps")
|
| 905 |
|
| 906 |
with gr.Tab("๐ค Individual Heatmaps"):
|
| 907 |
+
gr.Markdown("### Top 6 players with detailed activity analysis")
|
| 908 |
+
individual_heatmaps_output = gr.Image(label="Top Players Heatmaps")
|
| 909 |
|
| 910 |
with gr.Tab("๐ฎ Game Radar View"):
|
| 911 |
+
gr.Markdown("### Game-style tactical view with ball trail")
|
| 912 |
+
radar_output = gr.Image(label="Tactical Radar View")
|
| 913 |
|
| 914 |
analyze_btn.click(
|
| 915 |
fn=analyze_football_video,
|
|
|
|
| 926 |
|
| 927 |
gr.Markdown("""
|
| 928 |
---
|
| 929 |
+
### ๐ง Technical Details:
|
| 930 |
+
|
| 931 |
+
**Detection Models:**
|
| 932 |
+
- Player/Ball/Referee Detection: `football-players-detection-3zvbc/11`
|
| 933 |
+
- Field Keypoint Detection: `football-field-detection-f07vi/14`
|
| 934 |
+
|
| 935 |
+
**Tracking & Classification:**
|
| 936 |
+
- ByteTrack for persistent player IDs (60-frame buffer)
|
| 937 |
+
- SigLIP embeddings for team classification
|
| 938 |
+
- Majority voting for stable team assignments
|
| 939 |
+
|
| 940 |
+
**Performance Metrics:**
|
| 941 |
+
- Distance covered (meters)
|
| 942 |
+
- Average & maximum speed (m/s)
|
| 943 |
+
- Zone activity (defensive/middle/attacking thirds)
|
| 944 |
+
- Position heatmaps with Gaussian smoothing
|
| 945 |
+
|
| 946 |
+
**Ball Tracking:**
|
| 947 |
+
- Field homography transformation
|
| 948 |
+
- Outlier removal (500cm threshold)
|
| 949 |
+
- Transformation matrix smoothing (5-frame window)
|
| 950 |
+
|
| 951 |
+
### ๐ Output Files:
|
| 952 |
+
- Annotated video: `/tmp/annotated_football.mp4`
|
| 953 |
+
- Team heatmaps: `/tmp/team_heatmaps.png`
|
| 954 |
+
- Individual heatmaps: `/tmp/individual_heatmaps.png`
|
| 955 |
+
- Radar view: `/tmp/radar_view_enhanced.png`
|
| 956 |
""")
|
| 957 |
|
| 958 |
if __name__ == "__main__":
|
| 959 |
+
iface.launch(share=True)
|