|
9 | 9 |
|
10 | 10 | from pipelines.behavior_engine import BehaviorStateMachine, PossessionEngine |
11 | 11 | from pipelines.geometry import lift_keypoints_to_3d, project_pixel_to_court |
| 12 | +from pipelines.mvp_event_engine import MvpEventRuleEngine |
12 | 13 |
|
13 | 14 |
|
14 | 15 | def get_label_map(spec_path="specs/basketball_ncaa.yaml"): |
@@ -284,6 +285,108 @@ def write(self, payload): |
284 | 285 | self._fh.write(json.dumps(payload) + "\n") |
285 | 286 |
|
286 | 287 |
|
| 288 | +class MvpEventAdapter: |
| 289 | + """Translate legacy runtime events into MVP-attribution payloads. |
| 290 | +
|
| 291 | + The legacy inference path already emits simple `pass` / `catch` / `steal` |
| 292 | + events. This adapter preserves those rows and adds a parallel |
| 293 | + `attributed_event` contract shaped for the deterministic MVP event-rule |
| 294 | + engine. |
| 295 | + """ |
| 296 | + |
| 297 | + def __init__(self, rule_engine=None): |
| 298 | + self.rule_engine = rule_engine or MvpEventRuleEngine() |
| 299 | + |
| 300 | + def adapt(self, raw_event, player_map): |
| 301 | + kind = raw_event.get("kind") |
| 302 | + if kind == "catch": |
| 303 | + return [self._build_and_count( |
| 304 | + event_type="catch", |
| 305 | + actor_id=raw_event.get("player_id"), |
| 306 | + team_id=self._team_for(player_map, raw_event.get("player_id")), |
| 307 | + t_ms=raw_event.get("t_ms"), |
| 308 | + evidence={}, |
| 309 | + )] |
| 310 | + if kind == "pass": |
| 311 | + return [self._build_and_count( |
| 312 | + event_type="pass", |
| 313 | + actor_id=raw_event.get("from"), |
| 314 | + secondary_actor_id=raw_event.get("to"), |
| 315 | + team_id=raw_event.get("team_id") or self._team_for(player_map, raw_event.get("from")), |
| 316 | + t_ms=raw_event.get("t_ms"), |
| 317 | + next_event_type="catch", |
| 318 | + evidence={}, |
| 319 | + )] |
| 320 | + if kind == "steal": |
| 321 | + outputs = [] |
| 322 | + offender_id = raw_event.get("from") |
| 323 | + if offender_id is not None: |
| 324 | + outputs.append(self._build_and_count( |
| 325 | + event_type="turnover", |
| 326 | + actor_id=offender_id, |
| 327 | + secondary_actor_id=raw_event.get("player_id"), |
| 328 | + team_id=self._team_for(player_map, offender_id), |
| 329 | + t_ms=raw_event.get("t_ms"), |
| 330 | + next_event_type="steal", |
| 331 | + evidence={"loss_of_team_control": True}, |
| 332 | + )) |
| 333 | + outputs.append(self._build_and_count( |
| 334 | + event_type="steal", |
| 335 | + actor_id=raw_event.get("player_id"), |
| 336 | + secondary_actor_id=offender_id, |
| 337 | + team_id=raw_event.get("team_id") or self._team_for(player_map, raw_event.get("player_id")), |
| 338 | + t_ms=raw_event.get("t_ms"), |
| 339 | + evidence={"possession_gain": True}, |
| 340 | + )) |
| 341 | + return outputs |
| 342 | + return [] |
| 343 | + |
| 344 | + def _build_and_count( |
| 345 | + self, |
| 346 | + *, |
| 347 | + event_type, |
| 348 | + actor_id, |
| 349 | + team_id, |
| 350 | + t_ms, |
| 351 | + evidence, |
| 352 | + secondary_actor_id=None, |
| 353 | + preceding_event_type=None, |
| 354 | + next_event_type=None, |
| 355 | + terminal_event_type=None, |
| 356 | + shot_value=None, |
| 357 | + ): |
| 358 | + payload = { |
| 359 | + "kind": "attributed_event", |
| 360 | + "event_type": event_type, |
| 361 | + "actor_id": int(actor_id) if actor_id is not None else None, |
| 362 | + "secondary_actor_id": int(secondary_actor_id) if secondary_actor_id is not None else None, |
| 363 | + "team_id": int(team_id) if team_id is not None else None, |
| 364 | + "t_ms": int(t_ms) if t_ms is not None else None, |
| 365 | + "live_play": True, |
| 366 | + "preceding_event_type": preceding_event_type, |
| 367 | + "next_event_type": next_event_type, |
| 368 | + "terminal_event_type": terminal_event_type, |
| 369 | + "shot_value": shot_value, |
| 370 | + "evidence": evidence or {}, |
| 371 | + } |
| 372 | + payload["rule_validation"] = self.rule_engine.validate_event(payload) |
| 373 | + payload["stat_deltas"] = ( |
| 374 | + {} |
| 375 | + if payload["rule_validation"] |
| 376 | + else self.rule_engine.stat_deltas_for_event(payload) |
| 377 | + ) |
| 378 | + return payload |
| 379 | + |
| 380 | + @staticmethod |
| 381 | + def _team_for(player_map, player_id): |
| 382 | + if player_id is None: |
| 383 | + return None |
| 384 | + player_state = player_map.get(player_id) |
| 385 | + if not player_state: |
| 386 | + return None |
| 387 | + return player_state.get("team") |
| 388 | + |
| 389 | + |
287 | 390 | class FrameResultAdapter: |
288 | 391 | """Translate raw Ultralytics frame results into pipeline-friendly pieces. |
289 | 392 |
|
@@ -366,6 +469,7 @@ def __init__(self, config, models, calibration): |
366 | 469 | self.frame_idx = 0 |
367 | 470 | self.tracks = {} |
368 | 471 | self.possession_engine = PossessionEngine() |
| 472 | + self.mvp_event_adapter = MvpEventAdapter() |
369 | 473 | self.last_ball_2d = np.array([0.0, 0.0]) |
370 | 474 |
|
371 | 475 | def run(self): |
@@ -433,6 +537,8 @@ def _write_possession_events(self, player_map, ball_3d, t_ms, writer): |
433 | 537 | possession_events = self.possession_engine.update(player_map, ball_3d, t_ms) |
434 | 538 | for event in possession_events: |
435 | 539 | writer.write(event) |
| 540 | + for attributed_event in self.mvp_event_adapter.adapt(event, player_map): |
| 541 | + writer.write(attributed_event) |
436 | 542 |
|
437 | 543 | def _write_player_events(self, player_map, h_matrix, t_ms, writer): |
438 | 544 | """Emit one player-state row per currently visible tracked player.""" |
|
0 commit comments