Richard commited on
Commit
0ebda91
·
verified ·
1 Parent(s): 0a47c14

scorevision: push artifact

Browse files
Files changed (1) hide show
  1. miner.py +429 -0
miner.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from typing import List, Tuple, Dict
3
+ import sys
4
+ import os
5
+
6
+ from numpy import ndarray
7
+ from pydantic import BaseModel
8
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
9
+
10
+ from ultralytics import YOLO
11
+ from team_cluster import TeamClassifier
12
+ from utils import (
13
+ BoundingBox,
14
+ Constants,
15
+ )
16
+
17
+ import time
18
+ import torch
19
+ import gc
20
+ from pitch import process_batch_input, get_cls_net
21
+ import yaml
22
+
23
+
24
+ class BoundingBox(BaseModel):
25
+ x1: int
26
+ y1: int
27
+ x2: int
28
+ y2: int
29
+ cls_id: int
30
+ conf: float
31
+
32
+
33
+ class TVFrameResult(BaseModel):
34
+ frame_id: int
35
+ boxes: List[BoundingBox]
36
+ keypoints: List[Tuple[int, int]]
37
+
38
+
39
+ class Miner:
40
+ SMALL_CONTAINED_IOA = Constants.SMALL_CONTAINED_IOA
41
+ SMALL_RATIO_MAX = Constants.SMALL_RATIO_MAX
42
+ SINGLE_PLAYER_HUE_PIVOT = Constants.SINGLE_PLAYER_HUE_PIVOT
43
+ CORNER_INDICES = Constants.CORNER_INDICES
44
+ KEYPOINTS_CONFIDENCE = Constants.KEYPOINTS_CONFIDENCE
45
+ CORNER_CONFIDENCE = Constants.CORNER_CONFIDENCE
46
+ GOALKEEPER_POSITION_MARGIN = Constants.GOALKEEPER_POSITION_MARGIN
47
+ MIN_SAMPLES_FOR_FIT = 16 # Minimum player crops needed before fitting TeamClassifier
48
+ MAX_SAMPLES_FOR_FIT = 600 # Maximum samples to avoid overfitting
49
+
50
+ def __init__(self, path_hf_repo: Path) -> None:
51
+ try:
52
+ device = "cuda" if torch.cuda.is_available() else "cpu"
53
+ model_path = path_hf_repo / "football_object_detection.onnx"
54
+ self.bbox_model = YOLO(model_path)
55
+
56
+ print("BBox Model Loaded")
57
+
58
+ team_model_path = path_hf_repo / "osnet_model.pth.tar-100"
59
+ self.team_classifier = TeamClassifier(
60
+ device=device,
61
+ batch_size=32,
62
+ model_name=str(team_model_path)
63
+ )
64
+ print("Team Classifier Loaded")
65
+
66
+ # Team classification state
67
+ self.team_classifier_fitted = False
68
+ self.player_crops_for_fit = []
69
+
70
+ model_kp_path = path_hf_repo / 'keypoint'
71
+ config_kp_path = path_hf_repo / 'hrnetv2_w48.yaml'
72
+ cfg_kp = yaml.safe_load(open(config_kp_path, 'r'))
73
+
74
+ loaded_state_kp = torch.load(model_kp_path, map_location=device)
75
+ model = get_cls_net(cfg_kp)
76
+ model.load_state_dict(loaded_state_kp)
77
+ model.to(device)
78
+ model.eval()
79
+
80
+ self.keypoints_model = model
81
+ self.kp_threshold = 0.1
82
+ self.pitch_batch_size = 4
83
+ self.health = "healthy"
84
+ print("✅ Keypoints Model Loaded")
85
+ except Exception as e:
86
+ self.health = "❌ Miner initialization failed: " + str(e)
87
+ print(self.health)
88
+
89
+ def __repr__(self) -> str:
90
+ if self.health == 'healthy':
91
+ return (
92
+ f"health: {self.health}\n"
93
+ f"BBox Model: {type(self.bbox_model).__name__}\n"
94
+ f"Keypoints Model: {type(self.keypoints_model).__name__}"
95
+ )
96
+ else:
97
+ return self.health
98
+
99
+ def _calculate_iou(self, box1: Tuple[float, float, float, float],
100
+ box2: Tuple[float, float, float, float]) -> float:
101
+ """
102
+ Calculate Intersection over Union (IoU) between two bounding boxes.
103
+ Args:
104
+ box1: (x1, y1, x2, y2)
105
+ box2: (x1, y1, x2, y2)
106
+ Returns:
107
+ IoU score (0-1)
108
+ """
109
+ x1_1, y1_1, x2_1, y2_1 = box1
110
+ x1_2, y1_2, x2_2, y2_2 = box2
111
+
112
+ # Calculate intersection area
113
+ x_left = max(x1_1, x1_2)
114
+ y_top = max(y1_1, y1_2)
115
+ x_right = min(x2_1, x2_2)
116
+ y_bottom = min(y2_1, y2_2)
117
+
118
+ if x_right < x_left or y_bottom < y_top:
119
+ return 0.0
120
+
121
+ intersection_area = (x_right - x_left) * (y_bottom - y_top)
122
+
123
+ # Calculate union area
124
+ box1_area = (x2_1 - x1_1) * (y2_1 - y1_1)
125
+ box2_area = (x2_2 - x1_2) * (y2_2 - y1_2)
126
+ union_area = box1_area + box2_area - intersection_area
127
+
128
+ if union_area == 0:
129
+ return 0.0
130
+
131
+ return intersection_area / union_area
132
+
133
+ def _detect_objects_batch(self, decoded_images: List[ndarray]) -> Dict[int, List[BoundingBox]]:
134
+ batch_size = 16
135
+ detection_results = []
136
+ n_frames = len(decoded_images)
137
+ for frame_number in range(0, n_frames, batch_size):
138
+ batch_images = decoded_images[frame_number: frame_number + batch_size]
139
+ detections = self.bbox_model(batch_images, verbose=False, save=False)
140
+ detection_results.extend(detections)
141
+
142
+ return detection_results
143
+
144
+ def _team_classify(self, detection_results, decoded_images, offset):
145
+ self.team_classifier_fitted = False
146
+ start = time.time()
147
+ # Collect player crops from first batch for fitting
148
+ fit_sample_size = 600
149
+ player_crops_for_fit = []
150
+
151
+ for frame_id in range(len(detection_results)):
152
+ detection_box = detection_results[frame_id].boxes.data
153
+ if len(detection_box) < 4:
154
+ continue
155
+ # Collect player boxes for team classification fitting (first batch only)
156
+ if len(player_crops_for_fit) < fit_sample_size:
157
+ frame_image = decoded_images[frame_id]
158
+ for box in detection_box:
159
+ x1, y1, x2, y2, conf, cls_id = box.tolist()
160
+ if conf < 0.5:
161
+ continue
162
+ mapped_cls_id = str(int(cls_id))
163
+ # Only collect player crops (cls_id = 2)
164
+ if mapped_cls_id == '2':
165
+ crop = frame_image[int(y1):int(y2), int(x1):int(x2)]
166
+ if crop.size > 0:
167
+ player_crops_for_fit.append(crop)
168
+
169
+ # Fit team classifier after collecting samples
170
+ if self.team_classifier and not self.team_classifier_fitted and len(player_crops_for_fit) >= fit_sample_size:
171
+ print(f"Fitting TeamClassifier with {len(player_crops_for_fit)} player crops")
172
+ self.team_classifier.fit(player_crops_for_fit)
173
+ self.team_classifier_fitted = True
174
+ break
175
+ if not self.team_classifier_fitted and len(player_crops_for_fit) >= 16:
176
+ print(f"Fallback: Fitting TeamClassifier with {len(player_crops_for_fit)} player crops")
177
+ self.team_classifier.fit(player_crops_for_fit)
178
+ self.team_classifier_fitted = True
179
+ end = time.time()
180
+ print(f"Fitting Kmeans time: {end - start}")
181
+
182
+ # Second pass: predict teams with configurable frame skipping optimization
183
+ start = time.time()
184
+
185
+ # Get configuration for frame skipping
186
+ prediction_interval = 1 # Default: predict every 2 frames
187
+ iou_threshold = 0.3
188
+
189
+ print(f"Team classification - prediction_interval: {prediction_interval}, iou_threshold: {iou_threshold}")
190
+
191
+ # Storage for predicted frame results: {frame_id: {box_idx: (bbox, team_id)}}
192
+ predicted_frame_data = {}
193
+
194
+ # Step 1: Predict for frames at prediction_interval only
195
+ frames_to_predict = []
196
+ for frame_id in range(len(detection_results)):
197
+ if frame_id % prediction_interval == 0:
198
+ frames_to_predict.append(frame_id)
199
+
200
+ print(f"Predicting teams for {len(frames_to_predict)}/{len(detection_results)} frames "
201
+ f"(saving {100 - (len(frames_to_predict) * 100 // len(detection_results))}% compute)")
202
+
203
+ for frame_id in frames_to_predict:
204
+ detection_box = detection_results[frame_id].boxes.data
205
+ frame_image = decoded_images[frame_id]
206
+
207
+ # Collect player crops for this frame
208
+ frame_player_crops = []
209
+ frame_player_indices = []
210
+ frame_player_boxes = []
211
+
212
+ for idx, box in enumerate(detection_box):
213
+ x1, y1, x2, y2, conf, cls_id = box.tolist()
214
+ if cls_id == 2 and conf < 0.6:
215
+ continue
216
+ mapped_cls_id = str(int(cls_id))
217
+
218
+ # Collect player crops for prediction
219
+ if self.team_classifier and self.team_classifier_fitted and mapped_cls_id == '2':
220
+ crop = frame_image[int(y1):int(y2), int(x1):int(x2)]
221
+ if crop.size > 0:
222
+ frame_player_crops.append(crop)
223
+ frame_player_indices.append(idx)
224
+ frame_player_boxes.append((x1, y1, x2, y2))
225
+
226
+ # Predict teams for all players in this frame
227
+ if len(frame_player_crops) > 0:
228
+ team_ids = self.team_classifier.predict(frame_player_crops)
229
+ predicted_frame_data[frame_id] = {}
230
+ for idx, bbox, team_id in zip(frame_player_indices, frame_player_boxes, team_ids):
231
+ # Map team_id (0,1) to cls_id (6,7)
232
+ team_cls_id = str(6 + int(team_id))
233
+ predicted_frame_data[frame_id][idx] = (bbox, team_cls_id)
234
+
235
+ # Step 2: Process all frames (interpolate skipped frames)
236
+ fallback_count = 0
237
+ interpolated_count = 0
238
+ bboxes: dict[int, list[BoundingBox]] = {}
239
+ for frame_id in range(len(detection_results)):
240
+ detection_box = detection_results[frame_id].boxes.data
241
+ frame_image = decoded_images[frame_id]
242
+ boxes = []
243
+
244
+ team_predictions = {}
245
+
246
+ if frame_id % prediction_interval == 0:
247
+ # Predicted frame: use pre-computed predictions
248
+ if frame_id in predicted_frame_data:
249
+ for idx, (bbox, team_cls_id) in predicted_frame_data[frame_id].items():
250
+ team_predictions[idx] = team_cls_id
251
+ else:
252
+ # Skipped frame: interpolate from neighboring predicted frames
253
+ # Find nearest predicted frames
254
+ prev_predicted_frame = (frame_id // prediction_interval) * prediction_interval
255
+ next_predicted_frame = prev_predicted_frame + prediction_interval
256
+
257
+ # Collect current frame player boxes
258
+ for idx, box in enumerate(detection_box):
259
+ x1, y1, x2, y2, conf, cls_id = box.tolist()
260
+ if cls_id == 2 and conf < 0.6:
261
+ continue
262
+ mapped_cls_id = str(int(cls_id))
263
+
264
+ if self.team_classifier and self.team_classifier_fitted and mapped_cls_id == '2':
265
+ target_box = (x1, y1, x2, y2)
266
+
267
+ # Try to match with previous predicted frame
268
+ best_team_id = None
269
+ best_iou = 0.0
270
+
271
+ if prev_predicted_frame in predicted_frame_data:
272
+ team_id, iou = self._find_best_match(
273
+ target_box,
274
+ predicted_frame_data[prev_predicted_frame],
275
+ iou_threshold
276
+ )
277
+ if team_id is not None:
278
+ best_team_id = team_id
279
+ best_iou = iou
280
+
281
+ # Try to match with next predicted frame if available and no good match yet
282
+ if best_team_id is None and next_predicted_frame < len(detection_results):
283
+ if next_predicted_frame in predicted_frame_data:
284
+ team_id, iou = self._find_best_match(
285
+ target_box,
286
+ predicted_frame_data[next_predicted_frame],
287
+ iou_threshold
288
+ )
289
+ if team_id is not None and iou > best_iou:
290
+ best_team_id = team_id
291
+ best_iou = iou
292
+
293
+ # Track interpolation success
294
+ if best_team_id is not None:
295
+ interpolated_count += 1
296
+ else:
297
+ # Fallback: if no match found, predict individually
298
+ crop = frame_image[int(y1):int(y2), int(x1):int(x2)]
299
+ if crop.size > 0:
300
+ team_id = self.team_classifier.predict([crop])[0]
301
+ best_team_id = str(6 + int(team_id))
302
+ fallback_count += 1
303
+
304
+ if best_team_id is not None:
305
+ team_predictions[idx] = best_team_id
306
+
307
+ # Parse boxes with team classification
308
+ for idx, box in enumerate(detection_box):
309
+ x1, y1, x2, y2, conf, cls_id = box.tolist()
310
+ if cls_id == 2 and conf < 0.6:
311
+ continue
312
+
313
+ # Check overlap with staff box
314
+ overlap_staff = False
315
+ for idy, boxy in enumerate(detection_box):
316
+ s_x1, s_y1, s_x2, s_y2, s_conf, s_cls_id = boxy.tolist()
317
+ if cls_id == 2 and s_cls_id == 4:
318
+ staff_iou = self._calculate_iou(box[:4], boxy[:4])
319
+ if staff_iou >= 0.8:
320
+ overlap_staff = True
321
+ break
322
+ if overlap_staff:
323
+ continue
324
+
325
+ mapped_cls_id = str(int(cls_id))
326
+
327
+ # Override cls_id for players with team prediction
328
+ if idx in team_predictions:
329
+ mapped_cls_id = team_predictions[idx]
330
+ if mapped_cls_id != '4':
331
+ if int(mapped_cls_id) == 3 and conf < 0.5:
332
+ continue
333
+ boxes.append(
334
+ BoundingBox(
335
+ x1=int(x1),
336
+ y1=int(y1),
337
+ x2=int(x2),
338
+ y2=int(y2),
339
+ cls_id=int(mapped_cls_id),
340
+ conf=float(conf),
341
+ )
342
+ )
343
+ # Handle footballs - keep only the best one
344
+ footballs = [bb for bb in boxes if int(bb.cls_id) == 0]
345
+ if len(footballs) > 1:
346
+ best_ball = max(footballs, key=lambda b: b.conf)
347
+ boxes = [bb for bb in boxes if int(bb.cls_id) != 0]
348
+ boxes.append(best_ball)
349
+
350
+ bboxes[offset + frame_id] = boxes
351
+ return bboxes
352
+
353
+
354
+ def predict_batch(self, batch_images: List[ndarray], offset: int, n_keypoints: int) -> List[TVFrameResult]:
355
+ start = time.time()
356
+ detection_results = self._detect_objects_batch(batch_images)
357
+ end = time.time()
358
+ print(f"Detection time: {end - start}")
359
+ start = time.time()
360
+ bboxes = self._team_classify(detection_results, batch_images, offset)
361
+ end = time.time()
362
+ print(f"Team classify time: {end - start}")
363
+
364
+ pitch_batch_size = min(self.pitch_batch_size, len(batch_images))
365
+ keypoints: Dict[int, List[Tuple[int, int]]] = {}
366
+
367
+ start = time.time()
368
+ while True:
369
+ gc.collect()
370
+ if torch.cuda.is_available():
371
+ torch.cuda.empty_cache()
372
+ torch.cuda.synchronize()
373
+ device_str = "cuda"
374
+ keypoints_result = process_batch_input(
375
+ batch_images,
376
+ self.keypoints_model,
377
+ self.kp_threshold,
378
+ device_str,
379
+ batch_size=pitch_batch_size,
380
+ )
381
+ if keypoints_result is not None and len(keypoints_result) > 0:
382
+ for frame_number_in_batch, kp_dict in enumerate(keypoints_result):
383
+ if frame_number_in_batch >= len(batch_images):
384
+ break
385
+ frame_keypoints: List[Tuple[int, int]] = []
386
+ try:
387
+ height, width = batch_images[frame_number_in_batch].shape[:2]
388
+ if kp_dict is not None and isinstance(kp_dict, dict):
389
+ for idx in range(32):
390
+ x, y = 0, 0
391
+ kp_idx = idx + 1
392
+ if kp_idx in kp_dict:
393
+ try:
394
+ kp_data = kp_dict[kp_idx]
395
+ if isinstance(kp_data, dict) and "x" in kp_data and "y" in kp_data:
396
+ x = int(kp_data["x"] * width)
397
+ y = int(kp_data["y"] * height)
398
+ except (KeyError, TypeError, ValueError):
399
+ pass
400
+ frame_keypoints.append((x, y))
401
+ except (IndexError, ValueError, AttributeError):
402
+ frame_keypoints = [(0, 0)] * 32
403
+ if len(frame_keypoints) < n_keypoints:
404
+ frame_keypoints.extend([(0, 0)] * (n_keypoints - len(frame_keypoints)))
405
+ else:
406
+ frame_keypoints = frame_keypoints[:n_keypoints]
407
+ keypoints[offset + frame_number_in_batch] = frame_keypoints
408
+ break
409
+ end = time.time()
410
+ print(f"Keypoint time: {end - start}")
411
+
412
+
413
+ results: List[TVFrameResult] = []
414
+ for frame_number in range(offset, offset + len(batch_images)):
415
+ frame_boxes = bboxes.get(frame_number, [])
416
+ frame_keypoints = keypoints.get(frame_number, [(0, 0) for _ in range(n_keypoints)])
417
+ result = TVFrameResult(
418
+ frame_id=frame_number,
419
+ boxes=frame_boxes,
420
+ keypoints=frame_keypoints,
421
+ )
422
+ results.append(result)
423
+
424
+ gc.collect()
425
+ if torch.cuda.is_available():
426
+ torch.cuda.empty_cache()
427
+ torch.cuda.synchronize()
428
+
429
+ return results