From baf33dc190a8fdf18439e5f8fa8b67152cc0779a Mon Sep 17 00:00:00 2001 From: DivvyCr Date: Fri, 31 Jul 2020 15:05:22 +0100 Subject: [PATCH 1/6] Attempt #1 at finding all player contact in a game; then, determining who was the attacker/victim. --- .../events/bump_detection/bump_analysis.py | 196 +++++++++++++++++- 1 file changed, 188 insertions(+), 8 deletions(-) diff --git a/carball/analysis/events/bump_detection/bump_analysis.py b/carball/analysis/events/bump_detection/bump_analysis.py index a8774062..0a930772 100644 --- a/carball/analysis/events/bump_detection/bump_analysis.py +++ b/carball/analysis/events/bump_detection/bump_analysis.py @@ -1,3 +1,6 @@ +import itertools +import logging +import numpy as np import pandas as pd from carball.generated.api.stats.events_pb2 import Bump @@ -6,15 +9,30 @@ from carball.generated.api import game_pb2 +logger = logging.getLogger(__name__) +# If you decrease this, you risk not counting bumps where one car is directly behind another (driving in the same direction). +# If you increase this, you risk counting non-contact close proximity (e.g. one car cleanly jumped over another =/= bump). +PLAYER_CONTACT_MAX_DISTANCE = 200 + +# Needs to be relatively high to account for two cars colliding 'diagonally': /\ +MAX_BUMP_ALIGN_ANGLE = 60 + +# Currently arbitrary: +MIN_BUMP_VELOCITY = 5000 + +# Approx. half of goal height. +# (could be used to discard all aerial contact as bumps, although rarely an aerial bump WAS, indeed, intended) +AERIAL_BUMP_HEIGHT = 300 + +# TODO Analyse the impact of bumps. (e.g. look at proximity to the ball/net) class BumpAnalysis: def __init__(self, game: Game, proto_game: game_pb2): self.proto_game = proto_game - def get_bumps_from_game(self, data_frame: pd.DataFrame): + def get_bumps_from_game(self, data_frame: pd.DataFrame, player_map): self.create_bumps_from_demos(self.proto_game) - - self.analyze_bumps(data_frame) + self.create_bumps_from_player_contact(data_frame, player_map) def create_bumps_from_demos(self, proto_game): for demo in proto_game.game_metadata.demos: @@ -28,10 +46,172 @@ def add_bump(self, frame: int, victim_id: PlayerId, attacker_id: PlayerId, is_de if is_demo: bump.is_demo = True - def analyze_bumps(self, data_frame:pd.DataFrame): - for bump in self.proto_game.game_stats.bumps: - self.analyze_bump(bump, data_frame) + def create_bumps_from_player_contact(self, data_frame, player_map): + # NOTES: + # Currently, this yields more 'bumps' than there are actually. + # This is mostly due to aerial proximity, where a bump was NOT intended or no contact was made. + # This also occurs near the ground, where cars flip awkwardly past each other. + + # POSSIBLE SOLUTIONS: + # Account for car hitboxes and/or rotations when calculating close proximity intervals. + # Do some post-bump analysis and see if car velocities aligned (i.e. analyse end of interval bump alignments) + + # Get an array of player names to use for player combinations. + player_names = [] + for player in player_map.values(): + player_names.append(player.name) + + # For each player pair combination, get possible contact distances. + for player_pair in itertools.combinations(player_names, 2): + # Get all frame idxs where players were within PLAYER_CONTACT_MAX_DISTANCE. + players_close_frame_idxs = BumpAnalysis.get_players_close_frame_idxs(data_frame, + str(player_pair[0]), + str(player_pair[1])) + + if len(players_close_frame_idxs) > 0: + BumpAnalysis.analyse_bumps(data_frame, player_pair, players_close_frame_idxs) + else: + logger.info("Players (" + player_pair[0] + " and " + player_pair[1] + ") did not get close " + "during the match.") + + @staticmethod + def analyse_bumps(data_frame, player_pair, players_close_frame_idxs): + # Get all individual intervals where a player pair got close to each other. + players_close_frame_idxs_intervals = BumpAnalysis.get_players_close_intervals(players_close_frame_idxs) + + # For each such interval, take (currently only) the beginning and analyse car behaviour. + for interval in players_close_frame_idxs_intervals: + frame_before_bump = interval[0] + + # Calculate alignments (angle between position vector and velocity vector, with regard to each player) + p1_alignment_before = BumpAnalysis.get_player_bump_alignment(data_frame, frame_before_bump, + player_pair[0], player_pair[1]) + p2_alignment_before = BumpAnalysis.get_player_bump_alignment(data_frame, frame_before_bump, + player_pair[1], player_pair[0]) + # TODO Create Bump objects and add them to the API. + # Determine the attacker and the victim (if alignment is below MAX_BUMP_ALIGN_ANGLE, it's the attacker) + attacker, victim = BumpAnalysis.determine_attacker_victim(player_pair[0], player_pair[1], + p1_alignment_before, p2_alignment_before) + + # Determine if the bump was above AERIAL_BUMP_HEIGHT. + is_aerial_bump = BumpAnalysis.is_aerial_bump(data_frame, player_pair[0], player_pair[1], frame_before_bump) + + # Check if interval is quite long - players may be in rule 1 :) or might be a scramble. + BumpAnalysis.analyse_prolonged_proximity(data_frame, interval, player_pair[0], player_pair[1]) + + @staticmethod + def get_player_bump_alignment(data_frame, frame_idx, p1_name, p2_name): + p1_vel_df = data_frame[p1_name][['vel_x', 'vel_y', 'vel_z']].loc[frame_idx] + p1_pos_df = data_frame[p1_name][['pos_x', 'pos_y', 'pos_z']].loc[frame_idx] + p2_pos_df = data_frame[p2_name][['pos_x', 'pos_y', 'pos_z']].loc[frame_idx] + + # Get the distance vector, directed from p1 to p2. + # Then, convert it to a unit vector. + pos1_df = p2_pos_df - p1_pos_df + pos1 = [pos1_df.pos_x, pos1_df.pos_y, pos1_df.pos_y] + unit_pos1 = pos1 / np.linalg.norm(pos1) + + # Get the velocity vector of p1. + # Then, convert it to a unit vector. + vel1 = [p1_vel_df.vel_x, p1_vel_df.vel_y, p1_vel_df.vel_z] + unit_vel1 = vel1 / np.linalg.norm(vel1) + + # Find the angle between the position vector and the velocity vector. + # If this is relatively aligned - p1 probably significantly bumped p2. + ang = (np.arccos(np.clip(np.dot(unit_vel1, unit_pos1), -1.0, 1.0))) * 180 / np.pi + # print(p1_name + "'s bump angle=" + str(ang)) + return ang + + @staticmethod + def get_players_close_frame_idxs(data_frame, p1_name, p2_name): + p1_pos_df = data_frame[p1_name][['pos_x', 'pos_y', 'pos_z']].dropna(axis=0) + p2_pos_df = data_frame[p2_name][['pos_x', 'pos_y', 'pos_z']].dropna(axis=0) + + # Calculate the vector distances between the players. + distances = (p1_pos_df.pos_x - p2_pos_df.pos_x) ** 2 + \ + (p1_pos_df.pos_y - p2_pos_df.pos_y) ** 2 + \ + (p1_pos_df.pos_z - p2_pos_df.pos_z) ** 2 + distances = np.sqrt(distances) + # Only keep values < PLAYER_CONTACT_MAX_DISTANCE (see top of class). + players_close_series = distances[distances < PLAYER_CONTACT_MAX_DISTANCE] + # Get the frame indexes of the values (as ndarray). + players_close_frame_idxs = players_close_series.index.to_numpy() + return players_close_frame_idxs + + @staticmethod + def get_players_close_intervals(players_close_frame_idxs): + # Find continuous intervals of close proximity, and group them together. + all_intervals = [] + interval = [] + for index, frame_idx in enumerate(players_close_frame_idxs): + diffs = np.diff(players_close_frame_idxs) + interval.append(frame_idx) + if index >= len(diffs) or diffs[index] >= 3: + all_intervals.append(interval) + interval = [] + return all_intervals + + @staticmethod + def determine_attacker_victim(p1_name, p2_name, p1_alignment, p2_alignment): + """ + Try to 'guesstimate' the attacker and the victim by comparing bump alignment angles. + If both alignments are within 45deg, then both players were going relatively towards each other. + + :return: (Attacker, Victim) + """ + + if p1_alignment < MAX_BUMP_ALIGN_ANGLE or p2_alignment < MAX_BUMP_ALIGN_ANGLE: + if abs(p1_alignment - p2_alignment) < 45: + return None, None + elif p1_alignment < p2_alignment: + return p1_name, p2_name + elif p2_alignment < p1_alignment: + return p2_name, p1_name + + return None, None + + @staticmethod + def analyse_prolonged_proximity(data_frame, interval, p1_name, p2_name): + # TODO Redo this to do some proper analysis. + if len(interval) > 10: + print(" > Scramble between " + p1_name + " and " + p2_name) + # NOTE: Could try analysing immediate post-bump effects. + # elif len(interval) >= 5: + # frame_after_bump = interval[len(interval) - 1] + # p1_alignment_after = BumpAnalysis.get_player_bump_alignment(data_frame, frame_after_bump, + # p1_name, p2_name) + # p2_alignment_after = BumpAnalysis.get_player_bump_alignment(data_frame, frame_after_bump, + # p2_name, p1_name) + + @staticmethod + def is_aerial_bump(data_frame: pd.DataFrame, p1_name: str, p2_name: str, at_frame: int): + p1_pos_z = data_frame[p1_name].pos_z.loc[at_frame] + p2_pos_z = data_frame[p2_name].pos_z.loc[at_frame] + if all(x > AERIAL_BUMP_HEIGHT for x in [p1_pos_z, p2_pos_z]): + # if all(abs(y) > 5080 for y in [p1_pos_y, p2_pos_y]): + # print("Backboard bump?") + return True + else: + return False + @staticmethod + def is_bump_alignment(bump_angles): + # Check if all bump alignment angles in the first half of the interval are above MAX_BUMP_ALIGN_ANGLE. + if all(x > MAX_BUMP_ALIGN_ANGLE for x in bump_angles): + return False + else: + return True - def analyze_bump(self, bump: Bump, data_frame:pd.DataFrame): - frame_number = bump.frame_number + @staticmethod + def is_bump_velocity(data_frame: pd.DataFrame, p1_name: str, p2_name: str, at_frame: int): + p1_vel_mag = np.sqrt(data_frame[p1_name].vel_x.loc[at_frame] ** 2 + + data_frame[p1_name].vel_y.loc[at_frame] ** 2 + + data_frame[p1_name].vel_z.loc[at_frame] ** 2) + p2_vel_mag = np.sqrt(data_frame[p2_name].vel_x.loc[at_frame] ** 2 + + data_frame[p2_name].vel_y.loc[at_frame] ** 2 + + data_frame[p2_name].vel_z.loc[at_frame] ** 2) + # Check if initial player velocities are below MIN_BUMP_VELOCITY. + if all(x < MIN_BUMP_VELOCITY for x in [p1_vel_mag, p2_vel_mag]): + return False + else: + return True From 3d989c31435ce4775f48af420c85e03ed1abb4cb Mon Sep 17 00:00:00 2001 From: DivvyCr Date: Sun, 2 Aug 2020 11:52:49 +0100 Subject: [PATCH 2/6] Merge master into this + Small argument fix --- carball/analysis/events/event_creator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carball/analysis/events/event_creator.py b/carball/analysis/events/event_creator.py index adddde9c..3c27b6c4 100644 --- a/carball/analysis/events/event_creator.py +++ b/carball/analysis/events/event_creator.py @@ -92,7 +92,7 @@ def create_bumps(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[s data_frame: pd.DataFrame): logger.info("Looking for bumps.") bumpAnalysis = BumpAnalysis(game=game, proto_game=proto_game) - bumpAnalysis.get_bumps_from_game(data_frame) + bumpAnalysis.get_bumps_from_game(data_frame, player_map) logger.info("Found %s bumps.", len(proto_game.game_stats.bumps)) def create_dropshot_events(self, game: Game, proto_game: game_pb2.Game, player_map: Dict[str, Player]): From 38d8ef933eda54afb055bf0b918029a7bfac619d Mon Sep 17 00:00:00 2001 From: DivvyCr Date: Mon, 3 Aug 2020 12:31:45 +0100 Subject: [PATCH 3/6] Create API bumps. (+ some cleanup) --- .../events/bump_detection/bump_analysis.py | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/carball/analysis/events/bump_detection/bump_analysis.py b/carball/analysis/events/bump_detection/bump_analysis.py index 0a930772..805866e2 100644 --- a/carball/analysis/events/bump_detection/bump_analysis.py +++ b/carball/analysis/events/bump_detection/bump_analysis.py @@ -25,7 +25,7 @@ # (could be used to discard all aerial contact as bumps, although rarely an aerial bump WAS, indeed, intended) AERIAL_BUMP_HEIGHT = 300 -# TODO Analyse the impact of bumps. (e.g. look at proximity to the ball/net) + class BumpAnalysis: def __init__(self, game: Game, proto_game: game_pb2): self.proto_game = proto_game @@ -56,10 +56,12 @@ def create_bumps_from_player_contact(self, data_frame, player_map): # Account for car hitboxes and/or rotations when calculating close proximity intervals. # Do some post-bump analysis and see if car velocities aligned (i.e. analyse end of interval bump alignments) - # Get an array of player names to use for player combinations. + # An array of player names to get player combinations; and a dict of player names to their IDs to create bumps. player_names = [] + player_name_to_id = {} for player in player_map.values(): player_names.append(player.name) + player_name_to_id[player.name] = player.id # For each player pair combination, get possible contact distances. for player_pair in itertools.combinations(player_names, 2): @@ -69,13 +71,23 @@ def create_bumps_from_player_contact(self, data_frame, player_map): str(player_pair[1])) if len(players_close_frame_idxs) > 0: - BumpAnalysis.analyse_bumps(data_frame, player_pair, players_close_frame_idxs) + likely_bumps = BumpAnalysis.filter_bumps(data_frame, player_pair, players_close_frame_idxs) else: + likely_bumps = None logger.info("Players (" + player_pair[0] + " and " + player_pair[1] + ") did not get close " "during the match.") + for likely_bump in likely_bumps: + self.add_bump(likely_bump[0], player_name_to_id[likely_bump[2]], player_name_to_id[likely_bump[1]], + is_demo=False) + @staticmethod - def analyse_bumps(data_frame, player_pair, players_close_frame_idxs): + def filter_bumps(data_frame, player_pair, players_close_frame_idxs): + """ + Try to find 'real' bumps, and return them as a list of likely_bumps. + """ + likely_bumps = [] + # Get all individual intervals where a player pair got close to each other. players_close_frame_idxs_intervals = BumpAnalysis.get_players_close_intervals(players_close_frame_idxs) @@ -96,8 +108,15 @@ def analyse_bumps(data_frame, player_pair, players_close_frame_idxs): # Determine if the bump was above AERIAL_BUMP_HEIGHT. is_aerial_bump = BumpAnalysis.is_aerial_bump(data_frame, player_pair[0], player_pair[1], frame_before_bump) + if attacker is not None and victim is not None and not is_aerial_bump: + # SUGGESTION: Perhaps take frame in the middle of the interval? + high_probability_bump = (frame_before_bump, attacker, victim) + likely_bumps.append(high_probability_bump) + # Check if interval is quite long - players may be in rule 1 :) or might be a scramble. - BumpAnalysis.analyse_prolonged_proximity(data_frame, interval, player_pair[0], player_pair[1]) + # BumpAnalysis.analyse_prolonged_proximity(data_frame, interval, player_pair[0], player_pair[1]) + + return likely_bumps @staticmethod def get_player_bump_alignment(data_frame, frame_idx, p1_name, p2_name): From aa9bd9f3ab381a30a1a2212c2a65bc6a68a20b25 Mon Sep 17 00:00:00 2001 From: DivvyCr Date: Mon, 3 Aug 2020 12:55:26 +0100 Subject: [PATCH 4/6] Not double-counting demo bumps now. --- .../events/bump_detection/bump_analysis.py | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/carball/analysis/events/bump_detection/bump_analysis.py b/carball/analysis/events/bump_detection/bump_analysis.py index 805866e2..f07ea11d 100644 --- a/carball/analysis/events/bump_detection/bump_analysis.py +++ b/carball/analysis/events/bump_detection/bump_analysis.py @@ -11,8 +11,8 @@ logger = logging.getLogger(__name__) -# If you decrease this, you risk not counting bumps where one car is directly behind another (driving in the same direction). -# If you increase this, you risk counting non-contact close proximity (e.g. one car cleanly jumped over another =/= bump). +# Decreasing this, risks not counting bumps where one car is directly behind another (driving in the same direction). +# Increasing this, risks counting non-contact close proximity (e.g. one car cleanly jumped over another =/= bump). PLAYER_CONTACT_MAX_DISTANCE = 200 # Needs to be relatively high to account for two cars colliding 'diagonally': /\ @@ -26,6 +26,7 @@ AERIAL_BUMP_HEIGHT = 300 +# TODO Post-bump analysis // Bump impact analysis. class BumpAnalysis: def __init__(self, game: Game, proto_game: game_pb2): self.proto_game = proto_game @@ -38,14 +39,6 @@ def create_bumps_from_demos(self, proto_game): for demo in proto_game.game_metadata.demos: self.add_bump(demo.frame_number, demo.victim_id, demo.attacker_id, True) - def add_bump(self, frame: int, victim_id: PlayerId, attacker_id: PlayerId, is_demo: bool) -> Bump: - bump = self.proto_game.game_stats.bumps.add() - bump.frame_number = frame - bump.attacker_id.id = attacker_id.id - bump.victim_id.id = victim_id.id - if is_demo: - bump.is_demo = True - def create_bumps_from_player_contact(self, data_frame, player_map): # NOTES: # Currently, this yields more 'bumps' than there are actually. @@ -77,7 +70,24 @@ def create_bumps_from_player_contact(self, data_frame, player_map): logger.info("Players (" + player_pair[0] + " and " + player_pair[1] + ") did not get close " "during the match.") - for likely_bump in likely_bumps: + self.add_non_demo_bumps(likely_bumps, player_name_to_id) + + def add_bump(self, frame: int, victim_id: PlayerId, attacker_id: PlayerId, is_demo: bool) -> Bump: + bump = self.proto_game.game_stats.bumps.add() + bump.frame_number = frame + bump.attacker_id.id = attacker_id.id + bump.victim_id.id = victim_id.id + if is_demo: + bump.is_demo = True + + def add_non_demo_bumps(self, likely_bumps, player_name_to_id): + demo_frame_idxs = [] + for demo in self.proto_game.game_metadata.demos: + demo_frame_idxs.append(demo.frame_number) + + for likely_bump in likely_bumps: + likely_bump_frame_idx = likely_bump[0] + if not any(np.isclose(demo_frame_idxs, likely_bump_frame_idx, atol=10)): self.add_bump(likely_bump[0], player_name_to_id[likely_bump[2]], player_name_to_id[likely_bump[1]], is_demo=False) From 6922200a018bb4e2868ce3e0a0dbe3a1e51e73e5 Mon Sep 17 00:00:00 2001 From: DivvyCr Date: Mon, 3 Aug 2020 14:34:23 +0100 Subject: [PATCH 5/6] Clean-up. --- .../events/bump_detection/bump_analysis.py | 114 ++++++++++++------ 1 file changed, 78 insertions(+), 36 deletions(-) diff --git a/carball/analysis/events/bump_detection/bump_analysis.py b/carball/analysis/events/bump_detection/bump_analysis.py index f07ea11d..dee424b8 100644 --- a/carball/analysis/events/bump_detection/bump_analysis.py +++ b/carball/analysis/events/bump_detection/bump_analysis.py @@ -1,7 +1,11 @@ import itertools import logging +from typing import Dict + import numpy as np import pandas as pd +from carball.generated.api.player_pb2 import Player + from carball.generated.api.stats.events_pb2 import Bump from carball.generated.api.player_id_pb2 import PlayerId @@ -13,7 +17,7 @@ # Decreasing this, risks not counting bumps where one car is directly behind another (driving in the same direction). # Increasing this, risks counting non-contact close proximity (e.g. one car cleanly jumped over another =/= bump). -PLAYER_CONTACT_MAX_DISTANCE = 200 +PLAYER_CONTACT_DISTANCE = 200 # Needs to be relatively high to account for two cars colliding 'diagonally': /\ MAX_BUMP_ALIGN_ANGLE = 60 @@ -33,21 +37,23 @@ def __init__(self, game: Game, proto_game: game_pb2): def get_bumps_from_game(self, data_frame: pd.DataFrame, player_map): self.create_bumps_from_demos(self.proto_game) - self.create_bumps_from_player_contact(data_frame, player_map) + self.create_bumps_from_player_proximity(data_frame, player_map) def create_bumps_from_demos(self, proto_game): for demo in proto_game.game_metadata.demos: self.add_bump(demo.frame_number, demo.victim_id, demo.attacker_id, True) - def create_bumps_from_player_contact(self, data_frame, player_map): - # NOTES: - # Currently, this yields more 'bumps' than there are actually. - # This is mostly due to aerial proximity, where a bump was NOT intended or no contact was made. - # This also occurs near the ground, where cars flip awkwardly past each other. - - # POSSIBLE SOLUTIONS: - # Account for car hitboxes and/or rotations when calculating close proximity intervals. - # Do some post-bump analysis and see if car velocities aligned (i.e. analyse end of interval bump alignments) + def create_bumps_from_player_proximity(self, data_frame: pd.DataFrame, player_map: Dict[str, Player]): + """ + Attempt to find all instances between each possible player combination + where they got within PLAYER_CONTACT_DISTANCE. + Then, add each instance to the API. + + NOTES: + Currently, this yields more 'bumps' than there are actually. + This is mostly due to aerial proximity, where a bump was NOT intended or no contact was made. + This also occurs near the ground, where cars flip awkwardly past each other. + """ # An array of player names to get player combinations; and a dict of player names to their IDs to create bumps. player_names = [] @@ -56,23 +62,23 @@ def create_bumps_from_player_contact(self, data_frame, player_map): player_names.append(player.name) player_name_to_id[player.name] = player.id - # For each player pair combination, get possible contact distances. + # For each player pair combination (nCr), get all frames where they got close and then filter those as bumps. for player_pair in itertools.combinations(player_names, 2): - # Get all frame idxs where players were within PLAYER_CONTACT_MAX_DISTANCE. players_close_frame_idxs = BumpAnalysis.get_players_close_frame_idxs(data_frame, str(player_pair[0]), str(player_pair[1])) if len(players_close_frame_idxs) > 0: likely_bumps = BumpAnalysis.filter_bumps(data_frame, player_pair, players_close_frame_idxs) + self.add_non_demo_bumps(likely_bumps, player_name_to_id) else: - likely_bumps = None logger.info("Players (" + player_pair[0] + " and " + player_pair[1] + ") did not get close " "during the match.") - self.add_non_demo_bumps(likely_bumps, player_name_to_id) - def add_bump(self, frame: int, victim_id: PlayerId, attacker_id: PlayerId, is_demo: bool) -> Bump: + """ + Add a new bump to the proto_game object. + """ bump = self.proto_game.game_stats.bumps.add() bump.frame_number = frame bump.attacker_id.id = attacker_id.id @@ -81,10 +87,19 @@ def add_bump(self, frame: int, victim_id: PlayerId, attacker_id: PlayerId, is_de bump.is_demo = True def add_non_demo_bumps(self, likely_bumps, player_name_to_id): + """ + Add a new bump to the proto_game object. + This method takes an array of likely (filtered) bumps, in the following form: + (frame_idx, attacker_name, victim_name) + and carefully adds them to the proto_game object (i.e. check for demo duplicates). + """ + + # Get an array of demo frame idxs to compare later. demo_frame_idxs = [] for demo in self.proto_game.game_metadata.demos: demo_frame_idxs.append(demo.frame_number) + # For each bump tuple, if its frame index is not similar to a demo frame index, add it via add_bump(). for likely_bump in likely_bumps: likely_bump_frame_idx = likely_bump[0] if not any(np.isclose(demo_frame_idxs, likely_bump_frame_idx, atol=10)): @@ -94,42 +109,58 @@ def add_non_demo_bumps(self, likely_bumps, player_name_to_id): @staticmethod def filter_bumps(data_frame, player_pair, players_close_frame_idxs): """ - Try to find 'real' bumps, and return them as a list of likely_bumps. + Filter the frames where two players got close - the filtered frames are likely bumps. + + The main principle used is the angle between two vectors (aka 'alignment'): + the velocity vector of player A; + the positional vector of the difference between the positions of player B and player A. + Both of these vectors point away from player A, and if the angle between them is small - it is likely that + Player A bumped Player B. (Velocity going 'through' Player B's Position) + + Some further checks are done to categorise the bump (i.e. is_aerial_bump(), is_bump_velocity() ) """ likely_bumps = [] - # Get all individual intervals where a player pair got close to each other. + # Split a list of frame indexes into intervals where indexes are within 3 of each other (i.e. consecutive). players_close_frame_idxs_intervals = BumpAnalysis.get_players_close_intervals(players_close_frame_idxs) - # For each such interval, take (currently only) the beginning and analyse car behaviour. + # For each such interval, take (currently only) the first frame index and analyse car behaviour. for interval in players_close_frame_idxs_intervals: frame_before_bump = interval[0] - # Calculate alignments (angle between position vector and velocity vector, with regard to each player) + # Calculate both player bump alignments (see comment at method top). p1_alignment_before = BumpAnalysis.get_player_bump_alignment(data_frame, frame_before_bump, player_pair[0], player_pair[1]) p2_alignment_before = BumpAnalysis.get_player_bump_alignment(data_frame, frame_before_bump, player_pair[1], player_pair[0]) - # TODO Create Bump objects and add them to the API. - # Determine the attacker and the victim (if alignment is below MAX_BUMP_ALIGN_ANGLE, it's the attacker) + + # Determine the attacker and the victim (see method for more info). attacker, victim = BumpAnalysis.determine_attacker_victim(player_pair[0], player_pair[1], p1_alignment_before, p2_alignment_before) # Determine if the bump was above AERIAL_BUMP_HEIGHT. is_aerial_bump = BumpAnalysis.is_aerial_bump(data_frame, player_pair[0], player_pair[1], frame_before_bump) + # Append the current bump data to likely bumps, if there is an attacker and a victim + # and if it wasn't an aerial bump (most often it isn't intended, and there is often awkward behaviour). if attacker is not None and victim is not None and not is_aerial_bump: - # SUGGESTION: Perhaps take frame in the middle of the interval? - high_probability_bump = (frame_before_bump, attacker, victim) - likely_bumps.append(high_probability_bump) + likely_bump = (frame_before_bump, attacker, victim) + likely_bumps.append(likely_bump) - # Check if interval is quite long - players may be in rule 1 :) or might be a scramble. + # NOT YET IMPLEMENTED: Check if interval is quite long - players may be in rule 1 :) or might be a scramble. # BumpAnalysis.analyse_prolonged_proximity(data_frame, interval, player_pair[0], player_pair[1]) return likely_bumps @staticmethod def get_player_bump_alignment(data_frame, frame_idx, p1_name, p2_name): + """ + Calculate and return the angle between: + the velocity vector of player A; + the positional vector of the difference between the positions of player B and player A. + """ + + # Get the necessary data from the DataFrame at the given frame index. p1_vel_df = data_frame[p1_name][['vel_x', 'vel_y', 'vel_z']].loc[frame_idx] p1_pos_df = data_frame[p1_name][['pos_x', 'pos_y', 'pos_z']].loc[frame_idx] p2_pos_df = data_frame[p2_name][['pos_x', 'pos_y', 'pos_z']].loc[frame_idx] @@ -145,31 +176,41 @@ def get_player_bump_alignment(data_frame, frame_idx, p1_name, p2_name): vel1 = [p1_vel_df.vel_x, p1_vel_df.vel_y, p1_vel_df.vel_z] unit_vel1 = vel1 / np.linalg.norm(vel1) - # Find the angle between the position vector and the velocity vector. - # If this is relatively aligned - p1 probably significantly bumped p2. + # Find the angle between the positional vector and the velocity vector. + # NOTE: This is currently converted to DEGREES, not sure if this is bad..? ( - DivvyC) ang = (np.arccos(np.clip(np.dot(unit_vel1, unit_pos1), -1.0, 1.0))) * 180 / np.pi - # print(p1_name + "'s bump angle=" + str(ang)) return ang @staticmethod def get_players_close_frame_idxs(data_frame, p1_name, p2_name): + """ + For a pair of players, find all frame indexes where they got within PLAYER_CONTACT_DISTANCE of each other. + Note that they did NOT necessarily make contact. + """ + + # Separate the positional data of each given player from the full DataFrame and lose the NaN value rows. p1_pos_df = data_frame[p1_name][['pos_x', 'pos_y', 'pos_z']].dropna(axis=0) p2_pos_df = data_frame[p2_name][['pos_x', 'pos_y', 'pos_z']].dropna(axis=0) - # Calculate the vector distances between the players. + # Calculate the vector distances between the players, and store them as a pd.Series (1D DataFrame). distances = (p1_pos_df.pos_x - p2_pos_df.pos_x) ** 2 + \ (p1_pos_df.pos_y - p2_pos_df.pos_y) ** 2 + \ (p1_pos_df.pos_z - p2_pos_df.pos_z) ** 2 distances = np.sqrt(distances) - # Only keep values < PLAYER_CONTACT_MAX_DISTANCE (see top of class). - players_close_series = distances[distances < PLAYER_CONTACT_MAX_DISTANCE] - # Get the frame indexes of the values (as ndarray). + + # Only keep values < PLAYER_CONTACT_DISTANCE (see top of class). + players_close_series = distances[distances < PLAYER_CONTACT_DISTANCE] + # Get the frame indexes of the values (as an ndarray). players_close_frame_idxs = players_close_series.index.to_numpy() return players_close_frame_idxs @staticmethod def get_players_close_intervals(players_close_frame_idxs): - # Find continuous intervals of close proximity, and group them together. + """ + Separate a list of frame indexes into intervals with consecutive frame indexes. + E.g. [3, 4, 5, 7, 19, 21, 23, 24, 57] is turned into [[3, 4, 5, 7], [21, 23, 24], [57]] + """ + all_intervals = [] interval = [] for index, frame_idx in enumerate(players_close_frame_idxs): @@ -184,9 +225,10 @@ def get_players_close_intervals(players_close_frame_idxs): def determine_attacker_victim(p1_name, p2_name, p1_alignment, p2_alignment): """ Try to 'guesstimate' the attacker and the victim by comparing bump alignment angles. - If both alignments are within 45deg, then both players were going relatively towards each other. + If both bump alignments are above MAX_BUMP_ALIGN_ANGLE, both values are None (no solid attacker/victim) + If both bump alignments are within 45deg of each other, both values are None (both attackers) - :return: (Attacker, Victim) + :return: A tuple in the form (Attacker, Victim) or (None, None). """ if p1_alignment < MAX_BUMP_ALIGN_ANGLE or p2_alignment < MAX_BUMP_ALIGN_ANGLE: From d847e758083e1d6c82986cbe53e7a28395008669 Mon Sep 17 00:00:00 2001 From: DivvyCr Date: Tue, 4 Aug 2020 17:45:30 +0100 Subject: [PATCH 6/6] Implement testing. NOTE: Have to change demo test, because game_stats.bumps is now more populated. --- carball/tests/stats/bump_test.py | 16 ++++++++++++++++ carball/tests/stats/demo_test.py | 7 +++++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 carball/tests/stats/bump_test.py diff --git a/carball/tests/stats/bump_test.py b/carball/tests/stats/bump_test.py new file mode 100644 index 00000000..40ba394a --- /dev/null +++ b/carball/tests/stats/bump_test.py @@ -0,0 +1,16 @@ +from carball.tests.utils import get_raw_replays, run_analysis_test_on_replay + +from carball.analysis.analysis_manager import AnalysisManager + + +class Test_Bumps: + def test_calculate_bumps_correctly(self, replay_cache): + def test(analysis: AnalysisManager): + proto_game = analysis.get_protobuf_data() + count_bumps = 0 + for i in proto_game.game_stats.bumps: + if not i.is_demo: + count_bumps += 1 + assert count_bumps == 3 + + run_analysis_test_on_replay(test, get_raw_replays()["3_BUMPS"], cache=replay_cache) diff --git a/carball/tests/stats/demo_test.py b/carball/tests/stats/demo_test.py index 1b013362..66f51f9a 100644 --- a/carball/tests/stats/demo_test.py +++ b/carball/tests/stats/demo_test.py @@ -7,7 +7,10 @@ class Test_Demos: def test_calculate_demos_correctly(self, replay_cache): def test(analysis: AnalysisManager): proto_game = analysis.get_protobuf_data() - bumps = proto_game.game_stats.bumps - assert len(bumps) == 1 + count_demo_bumps = 0 + for i in proto_game.game_stats.bumps: + if i.is_demo: + count_demo_bumps += 1 + assert count_demo_bumps == 1 run_analysis_test_on_replay(test, get_raw_replays()["1_DEMO"], cache=replay_cache)